├── .github ├── FUNDING.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .gitmodules ├── COPYING ├── NOTES.md ├── README.md ├── build-aux └── flatpak │ ├── README.md │ ├── com.vixalien.muzika.Devel.json │ └── modules │ ├── blueprint-compiler.json │ ├── libportal.json │ ├── yarn-deps-sources.json │ └── yarn-deps.json ├── data ├── com.vixalien.muzika.data.gresource.xml.in ├── com.vixalien.muzika.desktop.in.in ├── com.vixalien.muzika.gschema.xml.in ├── com.vixalien.muzika.metainfo.xml.in.in ├── icons │ ├── hicolor │ │ ├── scalable │ │ │ └── apps │ │ │ │ ├── com.vixalien.muzika.Devel.svg │ │ │ │ └── com.vixalien.muzika.svg │ │ └── symbolic │ │ │ └── apps │ │ │ └── com.vixalien.muzika-symbolic.svg │ ├── meson.build │ └── scalable │ │ └── actions │ │ ├── compass2-symbolic.svg │ │ ├── explicit-symbolic.svg │ │ ├── heart-broken-symbolic.svg │ │ ├── history-undo-symbolic.svg │ │ ├── left-symbolic.svg │ │ ├── library-artists-symbolic.svg │ │ ├── library-music-symbolic.svg │ │ ├── library-symbolic.svg │ │ ├── moon-filled-symbolic.svg │ │ ├── music-artist-symbolic.svg │ │ ├── music-note-single-symbolic.svg │ │ ├── navigate-symbolic.svg │ │ ├── play-white-symbolic.svg │ │ ├── play-white.svg │ │ ├── playlist-symbolic.svg │ │ ├── playlist2-symbolic.svg │ │ ├── profit-symbolic.svg │ │ ├── progress-symbolic.svg │ │ ├── refresh-symbolic.svg │ │ ├── sentiment-satisfied-symbolic.svg │ │ ├── skip-backwards-10-symbolic.svg │ │ ├── skip-forward-10-symbolic.svg │ │ ├── sonar-symbolic.svg │ │ ├── sound-wave-symbolic.svg │ │ ├── subtitles-symbolic.svg │ │ ├── thumbs-down-symbolic.svg │ │ ├── thumbs-up-symbolic.svg │ │ ├── trend-down-symbolic.svg │ │ ├── trend-neutral-symbolic.svg │ │ └── trend-up-symbolic.svg ├── meson.build ├── resources │ └── screenshots │ │ ├── home.png │ │ └── playing.png ├── style.css └── ui │ ├── components │ ├── carousel │ │ ├── card.blp │ │ ├── carousel.blp │ │ ├── flatcard.blp │ │ └── moodbox.blp │ ├── dynamic-action.blp │ ├── dynamic-image.blp │ ├── library │ │ ├── history.blp │ │ ├── songs.blp │ │ └── view.blp │ ├── loading.blp │ ├── nav │ │ └── page.blp │ ├── navbar │ │ ├── button.blp │ │ ├── index.blp │ │ └── title.blp │ ├── paginator.blp │ ├── player │ │ ├── full.blp │ │ ├── mini.blp │ │ ├── now-playing │ │ │ ├── counterpart-switcher.blp │ │ │ ├── cover.blp │ │ │ ├── details │ │ │ │ ├── lyrics.blp │ │ │ │ ├── queue.blp │ │ │ │ ├── queueitem.blp │ │ │ │ └── related.blp │ │ │ ├── sheet.blp │ │ │ └── volume-control.blp │ │ ├── preview.blp │ │ └── video │ │ │ ├── controls.blp │ │ │ ├── view.blp │ │ │ └── volume-controls.blp │ ├── playlist │ │ ├── add-to-playlist-item.blp │ │ ├── bar.blp │ │ ├── edit.blp │ │ ├── header.blp │ │ ├── listitem.blp │ │ └── save-to-playlist.blp │ └── search │ │ ├── section.blp │ │ ├── topresult.blp │ │ └── topresultsection.blp │ ├── gtk │ └── help-overlay.blp │ ├── layout │ ├── panes.blp │ ├── shell.blp │ └── sidebar.blp │ ├── meson.build │ ├── pages │ ├── album.blp │ ├── artist-albums.blp │ ├── artist.blp │ ├── authentication-error.blp │ ├── channel-playlists.blp │ ├── channel.blp │ ├── charts.blp │ ├── error.blp │ ├── explore.blp │ ├── home.blp │ ├── login.blp │ ├── mood-playlists.blp │ ├── moods.blp │ ├── new-releases.blp │ ├── playlist.blp │ ├── preferences.blp │ └── search.blp │ └── window.blp ├── eslint.config.js ├── lib └── build.js ├── meson.build ├── meson_options.txt ├── package.json ├── po ├── LINGUAS ├── POTFILES ├── com.vixalien.muzika.pot ├── es.po ├── fr.po ├── ja.po ├── meson.build ├── nl.po ├── pt_BR.po ├── tr.po └── zh_TW.po ├── src ├── application.ts ├── com.vixalien.muzika.in ├── com.vixalien.muzika.src.gresource.xml ├── components │ ├── annotated-view.ts │ ├── carousel │ │ ├── card.ts │ │ ├── flatcard.ts │ │ ├── index.ts │ │ └── view │ │ │ ├── flatgrid.ts │ │ │ ├── flatlist.ts │ │ │ ├── grid.ts │ │ │ ├── list.ts │ │ │ ├── mood.ts │ │ │ └── util.ts │ ├── dynamic-action.ts │ ├── dynamic-image.ts │ ├── fixed-ratio-thumbnail.ts │ ├── library │ │ ├── mixedcard.ts │ │ └── view.ts │ ├── loading.ts │ ├── marquee.ts │ ├── maxheight.ts │ ├── nav │ │ └── page.ts │ ├── navbar │ │ ├── button.ts │ │ ├── index.ts │ │ └── title.ts │ ├── paginator.ts │ ├── player │ │ ├── full.ts │ │ ├── mini.ts │ │ ├── now-playing │ │ │ ├── counterpart-switcher.ts │ │ │ ├── cover.ts │ │ │ ├── details │ │ │ │ ├── lyrics.ts │ │ │ │ ├── queue.ts │ │ │ │ ├── queueitem.ts │ │ │ │ ├── related.ts │ │ │ │ └── switcher.ts │ │ │ ├── sheet.ts │ │ │ └── volume-control.ts │ │ ├── preview.ts │ │ ├── progress.ts │ │ ├── scale.ts │ │ ├── video │ │ │ ├── controls.ts │ │ │ ├── languages.ts │ │ │ ├── util.ts │ │ │ ├── view.ts │ │ │ └── volume-controls.ts │ │ └── view.ts │ ├── playlist │ │ ├── add-to-playlist-item.ts │ │ ├── bar.ts │ │ ├── columnview │ │ │ ├── columns │ │ │ │ ├── add.ts │ │ │ │ ├── album.ts │ │ │ │ ├── artist.ts │ │ │ │ ├── chart-rank.ts │ │ │ │ ├── cover-art.ts │ │ │ │ ├── duration.ts │ │ │ │ ├── menu.ts │ │ │ │ └── title.ts │ │ │ └── index.ts │ │ ├── edit.ts │ │ ├── header.ts │ │ ├── itemview.ts │ │ ├── listitem.ts │ │ ├── listview.ts │ │ └── save-to-playlist.ts │ ├── search │ │ ├── section.ts │ │ ├── topresultcard.ts │ │ └── topresultsection.ts │ └── webimage.ts ├── layout │ ├── panes.ts │ ├── shell.ts │ └── sidebar.ts ├── main.ts ├── meson.build ├── mpris.ts ├── muse.ts ├── navigation.ts ├── pages.ts ├── pages │ ├── album.ts │ ├── artist-albums.ts │ ├── artist.ts │ ├── authentication-error.ts │ ├── channel-playlists.ts │ ├── channel.ts │ ├── charts.ts │ ├── error.ts │ ├── explore.ts │ ├── home.ts │ ├── library │ │ ├── albums.ts │ │ ├── artists.ts │ │ ├── base.ts │ │ ├── history.ts │ │ ├── index.ts │ │ ├── playlists.ts │ │ ├── songs.ts │ │ └── subscriptions.ts │ ├── login.ts │ ├── mood-playlists.ts │ ├── moods.ts │ ├── new-releases.ts │ ├── playlist.ts │ ├── preferences.ts │ └── search.ts ├── player │ ├── helpers.ts │ ├── index.ts │ ├── mpd.ts │ ├── queue.ts │ ├── signal-adapter.ts │ └── stream.ts ├── polyfills │ ├── abortcontroller.ts │ ├── base64.ts │ ├── customevent.ts │ ├── domexception.ts │ └── fetch.ts ├── util │ ├── action.ts │ ├── controllers │ │ ├── background.ts │ │ ├── hold.ts │ │ └── inhibit.ts │ ├── hash.ts │ ├── label.ts │ ├── language.ts │ ├── list.ts │ ├── menu │ │ ├── index.ts │ │ ├── library.ts │ │ └── like.ts │ ├── objectcontainer.ts │ ├── orientation.ts │ ├── playablelist.ts │ ├── scrolled.ts │ ├── secret-store.ts │ ├── settings.ts │ ├── signal-listener.ts │ ├── text.ts │ ├── time.ts │ ├── volume.ts │ └── window.ts └── window.ts ├── subprojects └── blueprint-compiler.wrap ├── tsconfig.json ├── types ├── ambient.d.ts └── gettext.d.ts └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: vixalien 2 | custom: ["https://www.buymeacoffee.com/vixalien"] 3 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | name: CD 9 | jobs: 10 | flatpak: 11 | name: Flatter 12 | runs-on: ubuntu-latest 13 | container: 14 | image: ghcr.io/andyholmes/flatter/gnome:master 15 | options: --privileged 16 | permissions: 17 | contents: write 18 | 19 | strategy: 20 | matrix: 21 | arch: [x86_64, aarch64] 22 | max-parallel: 1 23 | fail-fast: false 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup QEMU 30 | if: matrix.arch == 'aarch64' 31 | id: qemu 32 | uses: docker/setup-qemu-action@v3 33 | with: 34 | platforms: arm64 35 | 36 | - name: Setup GPG 37 | id: gpg 38 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 39 | uses: crazy-max/ghaction-import-gpg@v6 40 | with: 41 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 42 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 43 | 44 | - name: Setup Flatpak SDK Extensions 45 | run: | 46 | flatpak --system update -y --noninteractive 47 | flatpak --system install -y --noninteractive flathub org.freedesktop.Sdk.Extension.node20/${{ matrix.arch }}/24.08 48 | 49 | - name: Build 50 | id: build 51 | uses: andyholmes/flatter@main 52 | with: 53 | files: build-aux/flatpak/com.vixalien.muzika.Devel.json 54 | arch: ${{ matrix.arch }} 55 | gpg-sign: ${{ steps.gpg.outputs.fingerprint }} 56 | upload-bundles: true 57 | flatpak-build-bundle-args: | 58 | --repo-url=https://vixalien.github.io/muzika/repo 59 | 60 | - name: Deploy 61 | uses: JamesIves/github-pages-deploy-action@releases/v4 62 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 63 | with: 64 | folder: ${{ steps.build.outputs.repository }} 65 | target-folder: repo 66 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | name: CI 9 | jobs: 10 | lint: 11 | name: ESLint 12 | runs-on: ubuntu-latest # on which machine to run 13 | steps: # list of steps 14 | - name: Install NodeJS 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | 19 | - name: Code Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | submodules: recursive 23 | 24 | - name: Install Dependencies 25 | run: yarn install --frozen-lockfile 26 | 27 | - name: Code Linting 28 | run: yarn lint 29 | 30 | - name: Typechecking 31 | run: yarn typecheck 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | .vscode/ 3 | .flatpak/ 4 | .flatpak-builder/ 5 | node_modules/ 6 | /subprojects/blueprint-compiler 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "gi-types"] 2 | path = gi-types 3 | url = https://gitlab.gnome.org/BrainBlasted/gi-typescript-definitions.git 4 | branch = nightly 5 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | ## browse 2 | 3 | - muzika:video:videoId 4 | - muzika:album:albumId 5 | - muzika:playlist:playlistId 6 | - muzika:artist:channelId 7 | - muzika:user:channelId 8 | 9 | ## search 10 | 11 | - muzika:search:hello+world 12 | 13 | ## library 14 | 15 | - muzika:library 16 | - muzika:library:artists 17 | - muzika:library:subscriptions 18 | - muzika:library:tracks 19 | - muzika:library:albums 20 | 21 | ## uploads 22 | 23 | - muzika:uploads 24 | - muzika:uploads:tracks 25 | - muzika:uploads:albums 26 | - muzika:uploads:artist 27 | - muzika:uploads:artist:artistId 28 | - muzika:uploads:album:albumId 29 | 30 | ## play 31 | 32 | - muzika:player:pause 33 | - muzika:player:play 34 | - muzika:player:play-pause 35 | - muzika:player:next 36 | - muzika:player:previous 37 | - muzika:player:shuffle 38 | - muzika:player:repeat 39 | 40 | - muzika:queue:next:videoId 41 | - muzika:queue:next-playlist:playlistId 42 | - muzika:queue:add:videoId 43 | - muzika:queue:add-playlist:videoId 44 | - muzika:queue:radio:radioParams 45 | 46 | - muzika:show:lyrics 47 | - muzika:show:queue 48 | - muzika:show:related 49 | 50 | ## playlists 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Muzika 2 | 3 | > [!CAUTION] 4 | > Muzika is currently unmaintained, and will be archived in the future. See https://github.com/vixalien/muzika/issues/220 for more details. 5 | 6 | Muzika is an elegant music streaming app. 7 | 8 | ![Muzika home page](data/resources/screenshots/home.png) 9 | 10 | ![Muzika playing "My Queen is Angela Davis"](data/resources/screenshots/playing.png) 11 | 12 | > Note: This is a work in progress. The app is not yet ready for production. 13 | 14 | ## Features 15 | 16 | - Personalized home screen 17 | - Search for songs, albums, artists, radios and playlists 18 | - Login with Google and access your playlists and more from your library 19 | - Play personalized radios & mixes 20 | - View song lyrics & related information 21 | - Browse artists, albums and playlists etc. 22 | 23 | ## Installation 24 | 25 | ### Using the latest Nightly Flatpak 26 | 27 | You can download the [latest Nightly flatpak](https://vixalien.github.io/muzika/muzika.flatpakref). 28 | 29 | ### From source 30 | 31 | Dependencies: 32 | 33 | - GNOME Builder 34 | 35 | 1. Clone the repository 36 | 37 | ```bash 38 | git clone https://github.com/vixalien/muzika.git --recurse-submodules 39 | ``` 40 | 41 | 2. Open the project in GNOME Builder and use "Build" to build the project. 42 | 43 | > Note: Using Meson and Ninja directly is no longer supported because Muzika 44 | > uses the latest (unreleased) libadwaita components. 45 | 46 | ## Navigation 47 | 48 | Muzika has a robust navigator that allows you to navigate through different 49 | pages by using muzika URIs. Some of them are documented below. 50 | 51 | The muzika URI has the form `muzika:endpoint:data`. URIs can also have query 52 | parameters. For example, `muzika:library?view=grid` will open the library page 53 | with the grid view. 54 | 55 | You can navigate to a muzika URI by directly typing it in the search bar and 56 | muzika will visit that page directly instead of searching it. 57 | 58 | You can also navigate to a given endpoint by triggering the navigator action 59 | manually: 60 | 61 | 1. Open the GTK inspector by pressing `Ctrl+Shift+I`. 62 | 2. Click on the `Actions` tab on the right. 63 | 3. Look for the action named `navigator.visit` and type in your URI in the 64 | `Parameter` field, then click `Activate`. 65 | 66 | ### Endpoints 67 | 68 | A list of all endpoints are [here](src/pages.ts). 69 | 70 | - `muzika:home` - Home page 71 | - `muzika:playlist:` - Playlist page. eg: 72 | `muzika:playlist:PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i`/ 73 | - `muzika:album:` - Album page. 74 | - `muzika:artist:` - Artist or Channel page. 75 | - `search:` - Search. Note that query must be URL encoded. eg: 76 | `search:hello%20world` searches for `hello world`. 77 | - `muzika:library` - Library. 78 | 79 | More endpoints will be added as Muzika supports more features. 80 | -------------------------------------------------------------------------------- /build-aux/flatpak/README.md: -------------------------------------------------------------------------------- 1 | # Flatpak Builds 2 | 3 | This directory contains the Flatpak Manifest and other modules required for 4 | building a Muzika Flatpak. 5 | 6 | ## Modules 7 | 8 | ### blueprint-compiler 9 | 10 | This module is responsible for transforming our UI files from the blueprint 11 | format (`.blp`) to the Builder XML format (`.xml`). The .xml files are the ones 12 | we package because they are the only ones compatible with GTK natively. 13 | 14 | ### yarn-deps 15 | 16 | These are JavaScript NPM modules that are used in the project. They are listed 17 | in the `package.json` at the root, and wrote into the lockfile `yarn.lock` by 18 | `yarn install`. We then convert the yarn.lock into a format the flatpak builder 19 | can understand using the following command that creates `yarn-deps-sources.json` 20 | 21 | ```sh 22 | yarn run generate-sources 23 | ``` 24 | -------------------------------------------------------------------------------- /build-aux/flatpak/com.vixalien.muzika.Devel.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "com.vixalien.muzika.Devel", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "master", 5 | "sdk": "org.gnome.Sdk", 6 | "sdk-extensions": [ 7 | "org.freedesktop.Sdk.Extension.node20" 8 | ], 9 | "tags": [ 10 | "nightly" 11 | ], 12 | "build-options": { 13 | "append-path": "/usr/lib/sdk/node20/bin:/app/bin" 14 | }, 15 | "command": "com.vixalien.muzika.Devel", 16 | "finish-args": [ 17 | "--share=ipc", 18 | "--share=network", 19 | "--device=dri", 20 | "--socket=wayland", 21 | "--socket=pulseaudio", 22 | "--socket=fallback-x11", 23 | "--env=GJS_DISABLE_JIT=1", 24 | "--own-name=org.mpris.MediaPlayer2.Muzika", 25 | "--talk-name=org.freedesktop.secrets" 26 | ], 27 | "cleanup": [ 28 | "/include", 29 | "/lib/pkgconfig", 30 | "/man", 31 | "/share/doc", 32 | "/share/gtk-doc", 33 | "/share/man", 34 | "/share/pkgconfig", 35 | "*.la", 36 | "*.a", 37 | "/yarn-mirror" 38 | ], 39 | "modules": [ 40 | "modules/blueprint-compiler.json", 41 | "modules/libportal.json", 42 | "modules/yarn-deps.json", 43 | { 44 | "name": "muzika", 45 | "buildsystem": "meson", 46 | "config-opts": [ 47 | "-Dyarnrc=/app/.yarnrc", 48 | "-Dprofile=development" 49 | ], 50 | "run-tests": true, 51 | "sources": [ 52 | { 53 | "type": "git", 54 | "path": "../..", 55 | "branch": "HEAD" 56 | } 57 | ] 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /build-aux/flatpak/modules/blueprint-compiler.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blueprint-compiler", 3 | "buildsystem": "meson", 4 | "sources": [ 5 | { 6 | "type": "git", 7 | "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler", 8 | "tag": "v0.10.0" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /build-aux/flatpak/modules/libportal.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "libportal", 3 | "buildsystem": "meson", 4 | "builddir": true, 5 | "config-opts": [ 6 | "-Dtests=false", 7 | "-Dbackend-gtk3=disabled", 8 | "-Dbackend-gtk4=enabled", 9 | "-Dbackend-qt5=disabled", 10 | "-Ddocs=false", 11 | "--libdir=lib" 12 | ], 13 | "sources": [ 14 | { 15 | "type": "git", 16 | "url": "https://github.com/flatpak/libportal.git", 17 | "tag": "0.7.1" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /build-aux/flatpak/modules/yarn-deps.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yarn-deps", 3 | "buildsystem": "simple", 4 | "build-commands": [ 5 | "/usr/lib/sdk/node20/enable.sh", 6 | "mkdir -p /app", 7 | "ls $FLATPAK_BUILDER_BUILDDIR", 8 | "cp -r $FLATPAK_BUILDER_BUILDDIR/flatpak-node/yarn-mirror/ /app", 9 | "echo $'yarn-offline-mirror \"/app/yarn-mirror\"' > /app/.yarnrc" 10 | ], 11 | "sources": [ 12 | "yarn-deps-sources.json" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /data/com.vixalien.muzika.desktop.in.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Muzika 3 | GenericName=Music Streaming 4 | Comment=Stream music online 5 | TryExec=@app-id@ 6 | Exec=@app-id@ %U 7 | Icon=@app-id@ 8 | Terminal=false 9 | Type=Application 10 | StartupNotify=true 11 | Categories=GNOME;GTK;Music;Audio;Video;AudioVideo;Player; 12 | # Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 13 | Keywords=music;player;media;audio;playlists;youtube;artists; 14 | X-Purism-FormFactor=Workstation;Mobile; 15 | X-SingleMainWindow=true 16 | -------------------------------------------------------------------------------- /data/com.vixalien.muzika.metainfo.xml.in.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | @app-id@ 4 | CC0-1.0 5 | GPL-3.0 6 | Muzika 7 | Elegant music streaming application 8 | 9 |

10 | Play music from millions of songs available on the internet for free. 11 | Search for songs, albums, artists, and more. Discover new music from 12 | playlists and get a personalized & curated experience. 13 |

14 |

Current features:

15 |
    16 |
  • play any music from YouTube Music
  • 17 |
  • allows sign in through OAuth with Google to play your playlists and more from your library or uploads
  • 18 |
  • search for songs, albums, artists, radios, community & featured playlists and more
  • 19 |
  • access personalised music radios & mixes
  • 20 |
21 |
22 | 23 | 24 | https://raw.githubusercontent.com/vixalien/muzika/main/data/resources/screenshots/home.png 25 | 26 | 27 | 28 | 29 | 30 | 31 | @app-id@.desktop 32 | https://github.com/vixalien/muzika 33 | https://github.com/vixalien/muzika/issues 34 | https://www.buymeacoffee.com/vixalien 35 | https://github.com/vixalien/muzika 36 | https://github.com/vixalien/muzika/tree/main/po 37 | 38 | Angelo Verlain 39 | 40 | hey_at_vixalien.com 41 | muzika 42 | 43 | mobile 44 | 45 | 46 | 360 47 | 48 | 49 | pointing 50 | keyboard 51 | touch 52 | tablet 53 | 54 | 55 | ModernToolkit 56 | 57 | 58 | @app-id@.desktop 59 |
60 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/com.vixalien.muzika-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | scalable_dir = join_paths('hicolor', 'scalable', 'apps') 2 | install_data( 3 | join_paths(scalable_dir, application_id + '.svg'), 4 | install_dir: join_paths(muzika_datadir, 'icons', scalable_dir) 5 | ) 6 | 7 | symbolic_dir = join_paths('hicolor', 'symbolic', 'apps') 8 | install_data( 9 | join_paths(symbolic_dir, base_name + '-symbolic.svg'), 10 | install_dir: join_paths(muzika_datadir, 'icons', symbolic_dir), 11 | rename: application_id + '-symbolic.svg' 12 | ) 13 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/compass2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/explicit-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/heart-broken-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/history-undo-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/left-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/library-artists-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/library-music-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/library-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/moon-filled-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/music-artist-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/music-note-single-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/navigate-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/play-white-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/play-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/playlist-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/playlist2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/profit-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/progress-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 35 | 39 | 45 | 46 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/refresh-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/sentiment-satisfied-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/skip-backwards-10-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/skip-forward-10-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/sonar-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/sound-wave-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/subtitles-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/thumbs-down-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/thumbs-up-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/trend-down-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/trend-neutral-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/trend-up-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | app_conf = configuration_data() 2 | app_conf.set('app-id', application_id) 3 | app_conf.set('gettext-package', gettext_package) 4 | 5 | desktop_file = i18n.merge_file( 6 | input: configure_file( 7 | input: base_name + '.desktop.in.in', 8 | output: '@BASENAME@', 9 | configuration: app_conf 10 | ), 11 | output: application_id + '.desktop', 12 | type: 'desktop', 13 | po_dir: '../po', 14 | install: true, 15 | install_dir: join_paths(muzika_datadir, 'applications') 16 | ) 17 | 18 | desktop_utils = find_program('desktop-file-validate', required: false) 19 | if desktop_utils.found() 20 | test('Validate desktop file', desktop_utils, args: [desktop_file]) 21 | endif 22 | 23 | appstream_file = i18n.merge_file( 24 | input: configure_file( 25 | input: base_name + '.metainfo.xml.in.in', 26 | output: base_name + '.metainfo.xml.in', 27 | configuration: app_conf 28 | ), 29 | output: application_id + '.metainfo.xml', 30 | po_dir: '../po', 31 | install: true, 32 | install_dir: join_paths(muzika_datadir, 'metainfo'), 33 | ) 34 | 35 | appstreamcli = find_program('appstreamcli', required: false) 36 | if (appstreamcli.found()) 37 | test('Validate appdata file', 38 | appstreamcli, 39 | args: ['validate', '--no-net', '--explain', appstream_file], 40 | workdir: meson.current_build_dir() 41 | ) 42 | endif 43 | 44 | gsettings_schema = configure_file( 45 | input: base_name + '.gschema.xml.in', 46 | output: application_id + '.gschema.xml', 47 | configuration: app_conf, 48 | install: true, 49 | install_dir: muzika_schemadir 50 | ) 51 | 52 | compile_schemas = find_program('glib-compile-schemas', required: false) 53 | 54 | compile_local_schemas = custom_target( 55 | 'compile_local_schemas', 56 | input: gsettings_schema, 57 | output: 'gschemas.compiled', 58 | command: [compile_schemas, meson.current_build_dir()] 59 | ) 60 | 61 | if compile_schemas.found() 62 | test('Validate schema file', 63 | compile_schemas, 64 | args: ['--strict', '--dry-run', meson.current_source_dir()]) 65 | endif 66 | 67 | subdir('ui') 68 | 69 | gresource_conf = configuration_data() 70 | gresource_conf.merge_from(app_conf) 71 | gresource_conf.set('ui-resources', ui_xml) 72 | 73 | data_res = gnome.compile_resources( 74 | application_id + '.data', 75 | configure_file( 76 | input: base_name + '.data.gresource.xml.in', 77 | output: application_id + '.data.gresource.xml.in', 78 | configuration: gresource_conf, 79 | ), 80 | gresource_bundle: true, 81 | install: true, 82 | install_dir: muzika_pkgdatadir, 83 | dependencies: [blueprints, appstream_file], 84 | ) 85 | 86 | subdir('icons') 87 | -------------------------------------------------------------------------------- /data/resources/screenshots/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vixalien/muzika/7fb11bbbd4a77d860e62a0159f08cd5c2ad39d48/data/resources/screenshots/home.png -------------------------------------------------------------------------------- /data/resources/screenshots/playing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vixalien/muzika/7fb11bbbd4a77d860e62a0159f08cd5c2ad39d48/data/resources/screenshots/playing.png -------------------------------------------------------------------------------- /data/ui/components/carousel/card.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $CarouselCard : Box { 5 | valign: start; 6 | halign: center; 7 | orientation: vertical; 8 | hexpand: false; 9 | spacing: 6; 10 | 11 | $DynamicImage dynamic_image { 12 | size: 160; 13 | } 14 | 15 | Box meta { 16 | orientation: vertical; 17 | spacing: 3; 18 | 19 | Label title { 20 | label: "Title"; 21 | max-width-chars: 1; 22 | ellipsize: end; 23 | xalign: 0; 24 | hexpand: true; 25 | wrap: true; 26 | 27 | styles [ 28 | "heading", 29 | ] 30 | } 31 | 32 | Box subtitles { 33 | spacing: 6; 34 | 35 | styles [ 36 | "dim-label", 37 | ] 38 | 39 | Image explicit { 40 | visible: false; 41 | valign: center; 42 | icon-name: "explicit-symbolic"; 43 | } 44 | 45 | Label subtitle { 46 | use-markup: true; 47 | hexpand: true; 48 | max-width-chars: 1; 49 | ellipsize: end; 50 | xalign: 0; 51 | wrap: true; 52 | 53 | styles [ 54 | "flat-links", 55 | ] 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /data/ui/components/carousel/carousel.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $Carousel : Box { 4 | spacing: 6; 5 | orientation: vertical; 6 | 7 | Box titlesandbuttons { 8 | spacing: 6; 9 | margin-start: 12; 10 | margin-end: 12; 11 | height-request: 42; 12 | 13 | Box titles { 14 | valign: center; 15 | orientation: vertical; 16 | 17 | Label subtitle { 18 | halign: start; 19 | ellipsize: end; 20 | 21 | styles [ 22 | "dim-label", 23 | ] 24 | } 25 | 26 | Label title { 27 | halign: start; 28 | ellipsize: end; 29 | 30 | styles [ 31 | "title-2", 32 | ] 33 | } 34 | } 35 | 36 | Box buttons { 37 | halign: end; 38 | hexpand: true; 39 | valign: center; 40 | spacing: 6; 41 | 42 | Button more_button { 43 | visible: false; 44 | label: _("More"); 45 | 46 | styles [ 47 | "rounded", 48 | ] 49 | } 50 | 51 | Button left_button { 52 | icon-name: "go-previous-symbolic"; 53 | overflow: hidden; 54 | clicked => $left_button_clicked_cb(); 55 | 56 | styles [ 57 | "rounded", 58 | ] 59 | } 60 | 61 | Button right_button { 62 | icon-name: "go-next-symbolic"; 63 | overflow: hidden; 64 | clicked => $right_button_clicked_cb(); 65 | 66 | styles [ 67 | "rounded", 68 | ] 69 | } 70 | } 71 | } 72 | 73 | Separator { 74 | styles [ 75 | "spacer", 76 | ] 77 | } 78 | 79 | Stack carousel_stack { 80 | hexpand: true; 81 | 82 | ScrolledWindow scrolled { 83 | hexpand: true; 84 | propagate-natural-height: true; 85 | vscrollbar-policy: never; 86 | hadjustment: Adjustment { 87 | changed => $sync_scroll_buttons(); 88 | value-changed => $sync_scroll_buttons(); 89 | }; 90 | 91 | styles [ 92 | "undershoot-start", 93 | "undershoot-end", 94 | ] 95 | } 96 | 97 | Box text { 98 | TextView text_view { 99 | hexpand: true; 100 | wrap-mode: word_char; 101 | left-margin: 12; 102 | right-margin: 12; 103 | editable: false; 104 | cursor-visible: false; 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /data/ui/components/carousel/flatcard.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $FlatCard : Box { 4 | width-request: 320; 5 | spacing: 12; 6 | hexpand: false; 7 | valign: center; 8 | orientation: horizontal; 9 | 10 | styles [ 11 | "hover-parent", 12 | ] 13 | 14 | $DynamicImage dynamic_image { 15 | size: 48; 16 | action-size: 16; 17 | // icon-size: 16; 18 | // persistent-play-button: false; 19 | } 20 | 21 | Box meta { 22 | orientation: vertical; 23 | valign: center; 24 | spacing: 3; 25 | 26 | Label title { 27 | ellipsize: end; 28 | xalign: 0; 29 | 30 | styles [ 31 | "heading", 32 | ] 33 | } 34 | 35 | Box subtitles { 36 | spacing: 6; 37 | 38 | styles [ 39 | "dim-label", 40 | ] 41 | 42 | Image explicit { 43 | visible: false; 44 | valign: center; 45 | icon-name: "explicit-symbolic"; 46 | } 47 | 48 | Label subtitle { 49 | use-markup: true; 50 | ellipsize: end; 51 | xalign: 0; 52 | 53 | styles [ 54 | "flat-links", 55 | ] 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /data/ui/components/carousel/moodbox.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MoodBox : Adw.Bin { 5 | width-request: 260; 6 | 7 | styles ["mood-box"] 8 | 9 | Label label { 10 | xalign: 0; 11 | ellipsize: end; 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /data/ui/components/dynamic-action.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $DynamicAction : Adw.Bin { 5 | overflow: hidden; 6 | 7 | Stack stack { 8 | visible: false; 9 | halign: end; 10 | valign: end; 11 | margin-end: 6; 12 | margin-bottom: 6; 13 | transition-type: crossfade; 14 | 15 | Button persistent_play { 16 | clicked => $play_cb(); 17 | 18 | styles [ "floating-button", "no-padding", "transparent" ] 19 | 20 | Image persistent_play_image { 21 | icon-name: "play-white"; 22 | } 23 | } 24 | 25 | Button play { 26 | clicked => $play_cb(); 27 | 28 | styles [ "osd", "floating-button", "no-padding" ] 29 | 30 | Image play_image { 31 | icon-name: "media-playback-start-symbolic"; 32 | } 33 | } 34 | 35 | Button pause { 36 | clicked => $pause_cb(); 37 | 38 | styles [ "osd", "floating-button", "no-padding" ] 39 | 40 | Image pause_image { 41 | icon-name: "media-playback-pause-symbolic"; 42 | } 43 | } 44 | 45 | Adw.Bin loading { 46 | styles [ "osd", "floating-button" ] 47 | 48 | Adw.Spinner spinner { 49 | halign: center; 50 | valign: center; 51 | } 52 | } 53 | 54 | Image wave { 55 | icon-name: "sound-wave-symbolic"; 56 | pixel-size: 48; 57 | 58 | styles ["osd", "floating-button"] 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /data/ui/components/dynamic-image.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $DynamicImage : Overlay { 5 | overflow: hidden; 6 | 7 | styles ["dynamic-image"] 8 | 9 | [overlay] 10 | $DynamicAction action {} 11 | 12 | [overlay] 13 | CheckButton check { 14 | visible: false; 15 | valign: center; 16 | halign: center; 17 | toggled => $check_toggled_cb(); 18 | 19 | styles ["selection-mode"] 20 | } 21 | 22 | Adw.Bin container {} 23 | 24 | EventControllerMotion { 25 | enter => $hover_enter_cb(); 26 | leave => $hover_leave_cb(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /data/ui/components/library/history.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HistoryPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: Adw.BreakpointBin { 16 | width-request: 200; 17 | height-request: 200; 18 | 19 | Adw.Breakpoint { 20 | condition ("max-width: 700sp") 21 | 22 | setters { 23 | item_view.show-column-view: false; 24 | } 25 | } 26 | 27 | ScrolledWindow scrolled { 28 | hexpand: true; 29 | hscrollbar-policy: never; 30 | 31 | $PlaylistItemView item_view { 32 | show-column-view: true; 33 | } 34 | } 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /data/ui/components/library/songs.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $LibrarySongsPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: Adw.BreakpointBin { 16 | width-request: 200; 17 | height-request: 200; 18 | 19 | Adw.Breakpoint { 20 | condition ("max-width: 700sp") 21 | 22 | setters { 23 | item_view.show-column-view: false; 24 | } 25 | } 26 | 27 | ScrolledWindow scrolled { 28 | hexpand: true; 29 | hscrollbar-policy: never; 30 | 31 | Box { 32 | orientation: vertical; 33 | spacing: 12; 34 | 35 | Box { 36 | styles ["toolbar"] 37 | 38 | DropDown drop_down { 39 | halign: end; 40 | hexpand: true; 41 | } 42 | } 43 | 44 | $PlaylistItemView item_view { 45 | show-column-view: true; 46 | } 47 | 48 | $Paginator paginator { 49 | activate => $load_more(); 50 | } 51 | } 52 | } 53 | }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /data/ui/components/library/view.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $LibraryView : Box { 4 | ScrolledWindow scrolled { 5 | hexpand: true; 6 | hscrollbar-policy: never; 7 | 8 | Box box { 9 | orientation: vertical; 10 | margin-top: 12; 11 | margin-bottom: 12; 12 | spacing: 12; 13 | 14 | Box tools { 15 | valign: start; 16 | margin-start: 12; 17 | margin-end: 12; 18 | 19 | Box view_toggle { 20 | styles [ 21 | "linked", 22 | ] 23 | 24 | ToggleButton grid_button { 25 | icon-name: "view-grid-symbolic"; 26 | } 27 | 28 | ToggleButton list_button { 29 | icon-name: "view-list-symbolic"; 30 | group: grid_button; 31 | toggled => $on_list_button_toggled_cb(); 32 | } 33 | } 34 | 35 | DropDown drop_down { 36 | halign: end; 37 | hexpand: true; 38 | } 39 | } 40 | 41 | Stack stack { 42 | vhomogeneous: false; 43 | transition-type: slide_left_right; 44 | 45 | [grid] 46 | $CarouselGridView grid { 47 | orientation: vertical; 48 | } 49 | 50 | [list] 51 | $FlatListView list { 52 | child-type: 2; 53 | } 54 | } 55 | 56 | $Paginator paginator { 57 | activate => $paginated_cb(); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /data/ui/components/loading.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $Loading : Box { 5 | visible: true; 6 | hexpand: true; 7 | vexpand: true; 8 | halign: center; 9 | margin-top: 32; 10 | margin-bottom: 32; 11 | 12 | Adw.Spinner { 13 | halign: center; 14 | valign: center; 15 | width-request: 32; 16 | height-request: 32; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /data/ui/components/nav/page.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $Page : $AdwNavigationPage { 5 | Stack stack { 6 | vhomogeneous: false; 7 | transition-type: crossfade; 8 | 9 | Adw.Bin loading { 10 | Adw.ToolbarView { 11 | [top] 12 | Adw.HeaderBar {} 13 | 14 | $Loading {} 15 | } 16 | } 17 | 18 | Adw.Bin content {} 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /data/ui/components/navbar/button.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $NavbarButton : Box { 4 | margin-start: 6; 5 | margin-end: 6; 6 | margin-bottom: 6; 7 | margin-top: 6; 8 | spacing: 6; 9 | 10 | Image image { 11 | icon-name: "go-home-symbolic"; 12 | } 13 | 14 | Label label { 15 | ellipsize: end; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /data/ui/components/navbar/index.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $NavbarView : Box { 4 | orientation: vertical; 5 | 6 | SearchEntry search { 7 | placeholder-text: _("Search…"); 8 | margin-start: 12; 9 | margin-end: 12; 10 | margin-bottom: 6; 11 | margin-top: 12; 12 | } 13 | 14 | ListView list_view { 15 | single-click-activate: true; 16 | styles ["navigation-sidebar"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /data/ui/components/navbar/title.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $NavbarTitle : Box { 4 | margin-start: 18; 5 | margin-bottom: 6; 6 | margin-end: 6; 7 | margin-top: 6; 8 | 9 | Label label { 10 | hexpand: true; 11 | label: ""; 12 | xalign: 0; 13 | 14 | styles [ 15 | "dim-label", 16 | ] 17 | } 18 | 19 | Box action { 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /data/ui/components/paginator.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $Paginator : Revealer { 5 | Box { 6 | valign: start; 7 | hexpand: true; 8 | halign: center; 9 | margin-top: 12; 10 | margin-bottom: 12; 11 | margin-start: 12; 12 | margin-end: 12; 13 | 14 | Stack stack { 15 | visible-child: button; 16 | transition-type: crossfade; 17 | 18 | Button button { 19 | label: _("Load More"); 20 | clicked => $on_button_clicked(); 21 | 22 | styles [ 23 | "pill", 24 | ] 25 | } 26 | 27 | [spinner] 28 | Adw.Spinner spinner { 29 | halign: center; 30 | valign: center; 31 | width-request: 30; 32 | height-request: 30; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /data/ui/components/player/mini.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $MiniPlayerView : Overlay { 4 | Box { 5 | styles [ 6 | "toolbar", 7 | ] 8 | 9 | Box song_info_box { 10 | spacing: 12; 11 | margin-bottom: 6; 12 | margin-top: 6; 13 | margin-start: 6; 14 | margin-end: 6; 15 | hexpand: true; 16 | 17 | $PlayerPreview player_preview { 18 | valign: center; 19 | size: 46; 20 | } 21 | 22 | Box now_playing_labels { 23 | orientation: vertical; 24 | spacing: 3; 25 | valign: center; 26 | homogeneous: true; 27 | 28 | Label title { 29 | halign: start; 30 | ellipsize: end; 31 | 32 | styles [ 33 | "heading", 34 | ] 35 | } 36 | 37 | Label subtitle { 38 | label: ""; 39 | halign: start; 40 | ellipsize: end; 41 | } 42 | } 43 | } 44 | 45 | Box buttons { 46 | halign: end; 47 | hexpand: true; 48 | valign: center; 49 | spacing: 6; 50 | 51 | Button play_button { 52 | icon-name: "media-playback-start-symbolic"; 53 | tooltip-text: _("Toggle Play/Pause"); 54 | action-name: "player.play-pause"; 55 | 56 | accessibility { 57 | label: _("Toggle Play/Pause"); 58 | } 59 | } 60 | 61 | Button next_button { 62 | icon-name: "media-skip-forward-symbolic"; 63 | tooltip-text: _("Play Next"); 64 | action-name: "queue.next"; 65 | 66 | accessibility { 67 | label: _("Play Next"); 68 | } 69 | } 70 | } 71 | } 72 | 73 | [overlay] 74 | $PlayerProgressBar {} 75 | } 76 | -------------------------------------------------------------------------------- /data/ui/components/player/now-playing/counterpart-switcher.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaNPCounterpartSwitcher : Adw.Bin { 5 | Adw.ToggleGroup toggle_group { 6 | Adw.Toggle { 7 | name: "song"; 8 | label: _("Song"); 9 | } 10 | 11 | Adw.Toggle { 12 | name: "video"; 13 | label: _("Video"); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /data/ui/components/player/now-playing/details/lyrics.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaNPLyrics : Stack { 5 | Adw.StatusPage no_lyrics { 6 | icon-name: "heart-broken-symbolic"; 7 | title: _("Lyrics are not available"); 8 | description: _("If the song has lyrics, they\'ll appear here when they become available"); 9 | } 10 | 11 | Adw.Spinner loading { 12 | halign: center; 13 | valign: center; 14 | width-request: 32; 15 | height-request: 32; 16 | } 17 | 18 | ScrolledWindow lyrics_window { 19 | hscrollbar-policy: never; 20 | 21 | TextView view { 22 | left-margin: 9; 23 | right-margin: 9; 24 | top-margin: 9; 25 | bottom-margin: 9; 26 | hexpand: true; 27 | 28 | styles [ 29 | "background", 30 | "transparent", 31 | ] 32 | 33 | editable: false; 34 | cursor-visible: false; 35 | wrap-mode: word_char; 36 | buffer: 37 | TextBuffer buffer { 38 | text: ""; 39 | } 40 | 41 | ; 42 | } 43 | } 44 | 45 | ScrolledWindow timed_window { 46 | hscrollbar-policy: never; 47 | 48 | Gtk.Box { 49 | orientation: vertical; 50 | spacing: 12; 51 | 52 | ListBox timed_listbox { 53 | margin-top: 12; 54 | selection-mode: browse; 55 | row-activated => $lyrics_row_activated(); 56 | map => $setup_timed_lyrics(); 57 | unmap => $clear_timed_lyrics(); 58 | 59 | styles [ 60 | "transparent", 61 | ] 62 | } 63 | 64 | Label timed_source { 65 | xalign: 0; 66 | margin-start: 12; 67 | margin-end: 12; 68 | margin-bottom: 300; 69 | 70 | styles ["dim-label"] 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /data/ui/components/player/now-playing/details/queue.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaNPQueue : Stack { 5 | Adw.StatusPage no_queue { 6 | icon-name: "playlist2-symbolic"; 7 | title: _("The Queue is empty"); 8 | description: _("Play some tracks to see the queue"); 9 | } 10 | 11 | Box queue_box { 12 | orientation: vertical; 13 | 14 | Box { 15 | Box details { 16 | margin-start: 9; 17 | margin-end: 9; 18 | margin-top: 9; 19 | margin-bottom: 9; 20 | 21 | Box labels { 22 | orientation: vertical; 23 | 24 | Label { 25 | label: _("Playing From"); 26 | xalign: 0; 27 | 28 | styles [ 29 | "caption", 30 | "dim-label", 31 | ] 32 | } 33 | 34 | Label playlist_label { 35 | use-markup: true; 36 | hexpand: true; 37 | max-width-chars: 1; 38 | ellipsize: end; 39 | xalign: 0; 40 | wrap: true; 41 | 42 | styles [ 43 | "flat-links", 44 | ] 45 | } 46 | } 47 | } 48 | 49 | Button { 50 | valign: center; 51 | margin-end: 6; 52 | 53 | styles ["flat"] 54 | 55 | Adw.ButtonContent { 56 | icon-name: "list-add-symbolic"; 57 | label: _("Save"); 58 | tooltip-text: _("Save to playlist"); 59 | } 60 | 61 | clicked => $on_queue_saved_cb(); 62 | } 63 | } 64 | 65 | ScrolledWindow params_window { 66 | vscrollbar-policy: never; 67 | hscrollbar-policy: external; 68 | 69 | Adw.ToggleGroup param_toggles { 70 | valign: start; 71 | halign: start; 72 | margin-start: 9; 73 | margin-end: 9; 74 | margin-bottom: 9; 75 | can-shrink: false; 76 | } 77 | } 78 | 79 | ScrolledWindow queue_window { 80 | hscrollbar-policy: never; 81 | vexpand: true; 82 | 83 | ListView list_view { 84 | styles [ 85 | "transparent", 86 | ] 87 | 88 | activate => $activate_cb(); 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /data/ui/components/player/now-playing/details/queueitem.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $MuzikaNPQueueItem : Box { 4 | margin-top: 6; 5 | margin-bottom: 6; 6 | margin-start: 9; 7 | margin-end: 9; 8 | valign: center; 9 | hexpand: true; 10 | spacing: 12; 11 | 12 | Image image { 13 | icon-name: "image-missing-symbolic"; 14 | pixel-size: 48; 15 | overflow: hidden; 16 | valign: start; 17 | 18 | styles [ 19 | "br-6", 20 | "card", 21 | ] 22 | } 23 | 24 | Box meta { 25 | orientation: vertical; 26 | valign: center; 27 | spacing: 3; 28 | 29 | Label title { 30 | ellipsize: end; 31 | xalign: 0; 32 | 33 | styles [ 34 | "heading", 35 | ] 36 | } 37 | 38 | Box subtitles { 39 | spacing: 6; 40 | 41 | styles [ 42 | "dim-label", 43 | ] 44 | 45 | Image explicit { 46 | visible: false; 47 | valign: center; 48 | icon-name: "explicit-symbolic"; 49 | } 50 | 51 | Label subtitle { 52 | use-markup: true; 53 | ellipsize: end; 54 | xalign: 0; 55 | 56 | styles [ 57 | "flat-links", 58 | ] 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /data/ui/components/player/now-playing/details/related.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaNPRelated : Stack { 5 | Adw.StatusPage no_related { 6 | icon-name: "heart-broken-symbolic"; 7 | title: _("No related music"); 8 | description: _("We couldn\'t find any music related to this track."); 9 | } 10 | 11 | Adw.Spinner loading { 12 | halign: center; 13 | valign: center; 14 | width-request: 32; 15 | height-request: 32; 16 | } 17 | 18 | ScrolledWindow related_window { 19 | hscrollbar-policy: never; 20 | 21 | Box box { 22 | orientation: vertical; 23 | margin-top: 12; 24 | margin-bottom: 12; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /data/ui/components/player/now-playing/sheet.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaNPSheet : Adw.Bin { 5 | $MuzikaMaxHeight { 6 | Adw.ToolbarView { 7 | [top] 8 | Adw.HeaderBar { 9 | title-widget: $MuzikaNPCounterpartSwitcher {}; 10 | } 11 | 12 | Stack stack { 13 | transition-type: crossfade; 14 | 15 | StackPage { 16 | name: "cover"; 17 | child: $MuzikaNPCover {}; 18 | } 19 | } 20 | 21 | [bottom] 22 | $MuzikaNPDetailsSwitcher switcher { 23 | margin-bottom: 3; 24 | margin-top: 3; 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /data/ui/components/player/now-playing/volume-control.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $MuzikaNPVolumeControl: Widget { 4 | Button volume_low_button { 5 | icon-name: 'audio-volume-low-symbolic'; 6 | action-name: 'volume.toggle-mute'; 7 | valign: center; 8 | 9 | styles [ 10 | "flat", 11 | "circular", 12 | ] 13 | } 14 | 15 | Scale volume_scale { 16 | hexpand: true; 17 | margin-end: 6; 18 | adjustment: Adjustment volume_adjustment { 19 | lower: 0; 20 | upper: 1; 21 | step-increment: 0.05; 22 | value: 1; 23 | 24 | notify::value => $adjustment_value_changed_cb(); 25 | }; 26 | 27 | EventControllerScroll { 28 | name: "volume-scroll"; 29 | flags: vertical; 30 | 31 | scroll => $volume_scale_scrolled_cb(); 32 | } 33 | } 34 | 35 | Image volume_high_image { 36 | icon-name: 'audio-volume-high-symbolic'; 37 | margin-end: 10; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /data/ui/components/player/preview.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Gdk 4.0; 3 | using Adw 1; 4 | 5 | template $PlayerPreview : Adw.Bin { 6 | css-name: "button"; 7 | overflow: hidden; 8 | 9 | styles ["br-6", "card"] 10 | 11 | Image image { 12 | styles ["black-bg"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /data/ui/components/player/video/controls.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | Popover volume_popover { 5 | $VolumeControls {} 6 | } 7 | 8 | template $VideoControls : Adw.Bin { 9 | Adw.Clamp clamp { 10 | margin-end: 6; 11 | margin-bottom: 6; 12 | margin-start: 6; 13 | valign: end; 14 | maximum-size: 800; 15 | tightening-threshold: 600; 16 | 17 | Box toolbar_box { 18 | valign: end; 19 | orientation: vertical; 20 | 21 | styles ["osd", "toolbar"] 22 | 23 | CenterBox { 24 | margin-top: 3; 25 | 26 | [start] 27 | Label progress_label { 28 | margin-start: 12; 29 | margin-end: 3; 30 | 31 | styles [ 32 | "numeric", 33 | "caption", 34 | ] 35 | } 36 | 37 | [center] 38 | Adw.WindowTitle window_title { 39 | halign: center; 40 | hexpand: true; 41 | margin-start: 12; 42 | margin-end: 12; 43 | } 44 | 45 | [end] 46 | Label duration_label { 47 | margin-start: 3; 48 | margin-end: 3; 49 | 50 | styles [ 51 | "numeric", 52 | "caption", 53 | ] 54 | } 55 | } 56 | 57 | $PlayerScale scale {} 58 | 59 | CenterBox { 60 | [start] 61 | MenuButton volume_button { 62 | icon-name: "audio-volume-high-symbolic"; 63 | direction: up; 64 | popover: volume_popover; 65 | tooltip-text: _("Adjust Volume"); 66 | 67 | styles [ 68 | "flat", 69 | ] 70 | } 71 | 72 | [center] 73 | Box { 74 | spacing: 6; 75 | 76 | Button { 77 | icon-name: "media-skip-backward-symbolic"; 78 | tooltip-text: _("Previous"); 79 | action-name: "queue.previous"; 80 | 81 | styles [ 82 | "flat", 83 | ] 84 | } 85 | 86 | Button play_button { 87 | tooltip-text: _("Toggle Play/Pause"); 88 | icon-name: "media-playback-start-symbolic"; 89 | action-name: "player.play-pause"; 90 | 91 | styles [ 92 | "flat", 93 | ] 94 | } 95 | 96 | Button { 97 | icon-name: "media-skip-forward-symbolic"; 98 | tooltip-text: _("Next"); 99 | action-name: "queue.next"; 100 | 101 | styles [ 102 | "flat", 103 | ] 104 | } 105 | } 106 | 107 | [end] 108 | MenuButton more_button { 109 | icon-name: "view-more-symbolic"; 110 | direction: up; 111 | tooltip-text: _("More Options"); 112 | 113 | styles [ 114 | "flat", 115 | ] 116 | } 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /data/ui/components/player/video/volume-controls.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $VolumeControls : Box { 5 | query-tooltip => $on_query_tooltip(); 6 | 7 | ToggleButton button { 8 | icon-name: "audio-volume-high-symbolic"; 9 | toggled => $on_togglebutton_toggled(); 10 | 11 | styles ["rounded", "flat"] 12 | } 13 | 14 | Scale scale { 15 | width-request: 200; 16 | adjustment: Adjustment adjustment { 17 | lower: 0.0; 18 | upper: 1.0; 19 | value: 0.5; 20 | }; 21 | 22 | value-changed => $on_scale_value_changed(); 23 | } 24 | } -------------------------------------------------------------------------------- /data/ui/components/playlist/add-to-playlist-item.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $AddToPlaylistItemCard : Box { 4 | spacing: 12; 5 | hexpand: false; 6 | valign: center; 7 | orientation: horizontal; 8 | 9 | styles ["hover-parent"] 10 | 11 | Image image { 12 | pixel-size: 48; 13 | icon-name: "image-missing-symbolic"; 14 | overflow: hidden; 15 | 16 | styles ["br-6", "card"] 17 | } 18 | 19 | Box meta { 20 | orientation: vertical; 21 | valign: center; 22 | spacing: 3; 23 | 24 | Label title { 25 | ellipsize: end; 26 | xalign: 0; 27 | 28 | styles [ 29 | "heading", 30 | ] 31 | } 32 | 33 | Box subtitles { 34 | spacing: 6; 35 | 36 | styles [ 37 | "dim-label", 38 | ] 39 | 40 | Label subtitle { 41 | ellipsize: end; 42 | xalign: 0; 43 | 44 | styles [ 45 | "flat-links", 46 | ] 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /data/ui/components/playlist/bar.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $PlaylistBar : Adw.Bin { 5 | valign: end; 6 | 7 | Revealer revealer { 8 | Adw.Clamp { 9 | tightening-threshold: 1200; 10 | maximum-size: 1400; 11 | 12 | ActionBar { 13 | ToggleButton select_all { 14 | icon-name: "edit-select-all-symbolic"; 15 | } 16 | 17 | [center] 18 | Label label { 19 | label: ""; 20 | } 21 | 22 | [end] 23 | MenuButton more { 24 | icon-name: "view-more-symbolic"; 25 | } 26 | 27 | [end] 28 | Button delete { 29 | visible: false; 30 | icon-name: "user-trash-symbolic"; 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /data/ui/components/playlist/edit.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $EditPlaylistDialog : Adw.PreferencesDialog { 5 | title: _("Edit Playlist"); 6 | 7 | Adw.PreferencesPage { 8 | title: _("Edit Playlist"); 9 | 10 | Adw.PreferencesGroup { 11 | title: _("General"); 12 | 13 | Adw.EntryRow title { 14 | title: _("Title"); 15 | } 16 | 17 | Adw.EntryRow description { 18 | title: _("Description"); 19 | } 20 | 21 | Adw.ComboRow privacy { 22 | title: _("Privacy"); 23 | factory: SignalListItemFactory {}; 24 | } 25 | } 26 | 27 | Adw.PreferencesGroup { 28 | Button save { 29 | halign: center; 30 | label: _("Save"); 31 | clicked => $save_cb(); 32 | 33 | styles [ 34 | "pill", 35 | "suggested-action", 36 | ] 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /data/ui/components/playlist/header.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $PlaylistHeader : Box { 5 | spacing: 24; 6 | hexpand: true; 7 | valign: start; 8 | margin-start: 12; 9 | margin-end: 12; 10 | 11 | Stack stack { 12 | Image image { 13 | valign: start; 14 | pixel-size: 240; 15 | overflow: hidden; 16 | icon-name: "image-missing-symbolic"; 17 | 18 | styles [ 19 | "br-9", 20 | "card", 21 | ] 22 | } 23 | 24 | Adw.Avatar avatar { 25 | valign: start; 26 | size: 240; 27 | text: "Avatar"; 28 | 29 | styles [ 30 | "rounded", 31 | "card", 32 | ] 33 | } 34 | } 35 | 36 | Box { 37 | valign: end; 38 | orientation: vertical; 39 | hexpand: true; 40 | 41 | Box { 42 | orientation: vertical; 43 | 44 | Label title { 45 | label: "Title"; 46 | xalign: 0; 47 | justify: left; 48 | wrap: true; 49 | 50 | styles [ 51 | "title-1", 52 | ] 53 | } 54 | 55 | Label subtitle { 56 | use-markup: true; 57 | hexpand: true; 58 | max-width-chars: 1; 59 | xalign: 0; 60 | wrap: true; 61 | 62 | styles [ 63 | "title-3", 64 | "bold-link" 65 | ] 66 | } 67 | } 68 | 69 | Box submeta { 70 | margin-top: 3; 71 | spacing: 6; 72 | 73 | styles [ 74 | "dim-label", 75 | ] 76 | 77 | Image explicit { 78 | icon-name: "explicit-symbolic"; 79 | } 80 | 81 | Label genre { 82 | xalign: 0; 83 | justify: left; 84 | } 85 | 86 | Label subtitle_separator { 87 | label: "•"; 88 | } 89 | 90 | Label year { 91 | xalign: 0; 92 | justify: left; 93 | } 94 | } 95 | 96 | Stack description_stack { 97 | vhomogeneous: false; 98 | hhomogeneous: false; 99 | margin-top: 24; 100 | 101 | Label description { 102 | hexpand: true; 103 | wrap: true; 104 | lines: 3; 105 | ellipsize: end; 106 | xalign: 0; 107 | } 108 | 109 | Label description_long { 110 | hexpand: true; 111 | vexpand: true; 112 | wrap: true; 113 | xalign: 0; 114 | } 115 | } 116 | 117 | Expander more { 118 | label: _("Read more"); 119 | margin-top: 10; 120 | activate => $on_expander_activate(); 121 | 122 | styles [ 123 | "inverted", 124 | "dim-label", 125 | ] 126 | } 127 | 128 | Box buttons { 129 | margin-top: 12; 130 | spacing: 6; 131 | 132 | Box primary_buttons { 133 | visible: false; 134 | spacing: 6; 135 | } 136 | 137 | Box secondary_buttons { 138 | visible: false; 139 | spacing: 6; 140 | } 141 | } 142 | } 143 | } 144 | 145 | -------------------------------------------------------------------------------- /data/ui/components/playlist/listitem.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $PlaylistListItem : Box { 4 | spacing: 12; 5 | hexpand: true; 6 | valign: center; 7 | 8 | styles [ "br-6" ] 9 | 10 | $DynamicImage dynamic_image { 11 | size: 48; 12 | action-size: 16; 13 | halign: center; 14 | 15 | styles [ "br-6" ] 16 | } 17 | 18 | Box chart_rank { 19 | visible: false; 20 | width-request: 36; 21 | hexpand: false; 22 | 23 | Box { 24 | valign: center; 25 | hexpand: true; 26 | halign: center; 27 | spacing: 6; 28 | 29 | Label rank { 30 | label: "1"; 31 | } 32 | 33 | Image change { 34 | icon-name: "pan-down-symbolic"; 35 | } 36 | } 37 | } 38 | 39 | Box meta { 40 | orientation: vertical; 41 | valign: center; 42 | spacing: 3; 43 | hexpand: true; 44 | 45 | Inscription title { 46 | text-overflow: ellipsize_end; 47 | xalign: 0; 48 | 49 | styles [ 50 | "heading", 51 | ] 52 | } 53 | 54 | Box subtitles { 55 | spacing: 6; 56 | 57 | styles [ 58 | "dim-label", 59 | ] 60 | 61 | Image explicit { 62 | visible: false; 63 | valign: center; 64 | icon-name: "explicit-symbolic"; 65 | } 66 | 67 | Label subtitle { 68 | ellipsize: end; 69 | xalign: 0; 70 | 71 | styles [ 72 | "flat-links", 73 | ] 74 | } 75 | } 76 | } 77 | 78 | Button add { 79 | icon-name: "list-add-symbolic"; 80 | visible: false; 81 | clicked => $add_cb(); 82 | 83 | styles ["flat"] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /data/ui/components/playlist/save-to-playlist.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SaveToPlaylistDialog : Adw.Dialog { 5 | title: _("Save to playlist"); 6 | content-width: 360; 7 | content-height: 460; 8 | 9 | Adw.ToolbarView { 10 | [top] 11 | Adw.HeaderBar { 12 | [start] 13 | Button { 14 | // TODO 15 | icon-name: "list-add-symbolic"; 16 | tooltip-text: _("New playlist"); 17 | visible: false; 18 | } 19 | } 20 | 21 | ScrolledWindow { 22 | ListView list_view { 23 | single-click-activate: true; 24 | 25 | styles ["transparent", "playlist-list-view", "padded-top-bottom"] 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /data/ui/components/search/section.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $SearchSection : Box { 4 | valign: start; 5 | orientation: vertical; 6 | 7 | Box { 8 | margin-start: 12; 9 | margin-end: 12; 10 | margin-bottom: 6; 11 | height-request: 32; 12 | 13 | Label title { 14 | styles [ 15 | "title-2", 16 | ] 17 | } 18 | 19 | Button more { 20 | visible: false; 21 | halign: end; 22 | hexpand: true; 23 | label: _("More"); 24 | 25 | styles [ 26 | "rounded", 27 | ] 28 | } 29 | } 30 | 31 | $FlatListView card_view { 32 | child-type: 1; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /data/ui/components/search/topresult.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $TopResultCard : Adw.Bin { 5 | margin-start: 6; 6 | margin-end: 6; 7 | 8 | styles [ 9 | "card", 10 | "activatable", 11 | ] 12 | 13 | Grid grid { 14 | margin-top: 12; 15 | margin-bottom: 12; 16 | margin-start: 12; 17 | margin-end: 12; 18 | column-spacing: 12; 19 | row-spacing: 12; 20 | valign: center; 21 | 22 | Stack image_stack { 23 | layout { 24 | row-span: 2; 25 | } 26 | 27 | $DynamicImage dynamic_image { 28 | size: 100; 29 | action-size: 32; 30 | valign: center; 31 | persistent-play-button: false; 32 | } 33 | 34 | Adw.Avatar avatar { 35 | size: 100; 36 | } 37 | } 38 | 39 | Box meta { 40 | orientation: vertical; 41 | valign: center; 42 | spacing: 3; 43 | 44 | Label title { 45 | label: ""; 46 | hexpand: true; 47 | max-width-chars: 1; 48 | ellipsize: end; 49 | lines: 2; 50 | xalign: 0; 51 | wrap: true; 52 | wrap-mode: char; 53 | 54 | styles [ 55 | "heading", 56 | ] 57 | } 58 | 59 | Box subtitles { 60 | spacing: 6; 61 | 62 | styles [ 63 | "dim-label", 64 | ] 65 | 66 | Image explicit { 67 | visible: false; 68 | valign: center; 69 | icon-name: "explicit-symbolic"; 70 | } 71 | 72 | Label subtitle { 73 | use-markup: true; 74 | hexpand: true; 75 | max-width-chars: 1; 76 | ellipsize: end; 77 | xalign: 0; 78 | wrap: true; 79 | 80 | styles [ 81 | "flat-links", 82 | ] 83 | } 84 | } 85 | } 86 | 87 | Box actions { 88 | spacing: 12; 89 | 90 | layout { 91 | row: "1"; 92 | column: "1"; 93 | } 94 | 95 | Button primary { 96 | styles [ 97 | "pill", 98 | "suggested-action", 99 | ] 100 | 101 | Adw.ButtonContent primary_content { 102 | icon-name: "media-playback-start-symbolic"; 103 | label: _("Play"); 104 | halign: center; 105 | } 106 | } 107 | 108 | Button secondary { 109 | styles [ 110 | "pill", 111 | ] 112 | 113 | Adw.ButtonContent secondary_content { 114 | icon-name: "media-playlist-shuffle-symbolic"; 115 | label: _("Shuffle"); 116 | halign: center; 117 | } 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /data/ui/components/search/topresultsection.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $TopResultSection : Box { 4 | valign: start; 5 | orientation: vertical; 6 | 7 | Box { 8 | margin-start: 12; 9 | margin-end: 12; 10 | margin-bottom: 6; 11 | height-request: 32; 12 | 13 | Label { 14 | label: _("Top Result"); 15 | 16 | styles [ 17 | "title-2", 18 | ] 19 | } 20 | } 21 | 22 | Box box { 23 | spacing: 6; 24 | margin-start: 6; 25 | margin-end: 6; 26 | 27 | $TopResultCard card {} 28 | 29 | $FlatListView list_view { 30 | visible: false; 31 | hexpand: true; 32 | child-type: 1; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /data/ui/gtk/help-overlay.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | ShortcutsWindow help_overlay { 4 | modal: true; 5 | 6 | ShortcutsSection { 7 | section-name: "shortcuts"; 8 | max-height: 10; 9 | 10 | ShortcutsGroup { 11 | title: C_("shortcut window", "General"); 12 | 13 | ShortcutsShortcut { 14 | title: C_("shortcut window", "Show Shortcuts"); 15 | action-name: "win.show-help-overlay"; 16 | } 17 | 18 | ShortcutsShortcut { 19 | title: C_("shortcut window", "Quit"); 20 | action-name: "app.quit"; 21 | } 22 | } 23 | 24 | ShortcutsGroup { 25 | title: C_("shortcut window", "Video Player"); 26 | 27 | ShortcutsShortcut { 28 | title: C_("shortcut window", "Play/Pause"); 29 | accelerator: "p"; 30 | } 31 | 32 | ShortcutsShortcut { 33 | title: C_("shortcut window", "Close Video Player"); 34 | accelerator: "Escape"; 35 | } 36 | 37 | ShortcutsShortcut { 38 | title: C_("shortcut window", "Increase Volume"); 39 | accelerator: "plus"; 40 | } 41 | 42 | ShortcutsShortcut { 43 | title: C_("shortcut window", "Decrease Volume"); 44 | accelerator: "minus"; 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /data/ui/layout/panes.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaPanes : Adw.BreakpointBin { 5 | width-request: 360; 6 | height-request: 294; 7 | 8 | Adw.Breakpoint { 9 | condition ("max-width: 600sp") 10 | 11 | setters { 12 | split_view.collapsed: true; 13 | } 14 | } 15 | 16 | Adw.NavigationSplitView split_view { 17 | min-sidebar-width: 250; 18 | max-sidebar-width: 350; 19 | show-content: true; 20 | sidebar: Adw.NavigationPage sidebar_page { 21 | title: _("Muzika"); 22 | }; 23 | content: Adw.NavigationPage content_page { 24 | title: _("Content"); 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /data/ui/layout/sidebar.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $WindowSidebar : Adw.Bin { 5 | child: Adw.ToolbarView { 6 | hexpand: false; 7 | 8 | [top] 9 | Adw.HeaderBar { 10 | [start] 11 | MenuButton account { 12 | icon-name: "avatar-default-symbolic"; 13 | tooltip-text: _("Account"); 14 | visible: false; 15 | } 16 | 17 | [start] 18 | Button login { 19 | icon-name: "contact-new-symbolic"; 20 | action-name: "win.login"; 21 | tooltip-text: _("Log In"); 22 | } 23 | 24 | [end] 25 | MenuButton { 26 | icon-name: "open-menu-symbolic"; 27 | menu-model: primary_menu; 28 | } 29 | } 30 | content: 31 | ScrolledWindow { 32 | vexpand: true; 33 | 34 | $NavbarView navbar { 35 | searched => $navbar_searched_cb(); 36 | activated => $navbar_activated_cb(); 37 | } 38 | } 39 | 40 | ; 41 | }; 42 | } 43 | 44 | menu primary_menu { 45 | section { 46 | item { 47 | label: _("_Preferences"); 48 | action: "app.preferences"; 49 | } 50 | 51 | item { 52 | label: _("_About Muzika"); 53 | action: "app.about"; 54 | } 55 | 56 | item { 57 | label: _("_Quit"); 58 | action: "app.quit"; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /data/ui/meson.build: -------------------------------------------------------------------------------- 1 | blueprint_files = [ 2 | 'components/carousel/card.blp', 3 | 'components/carousel/carousel.blp', 4 | 'components/carousel/flatcard.blp', 5 | 'components/carousel/moodbox.blp', 6 | 'components/dynamic-action.blp', 7 | 'components/dynamic-image.blp', 8 | 'components/library/history.blp', 9 | 'components/library/songs.blp', 10 | 'components/library/view.blp', 11 | 'components/loading.blp', 12 | 'components/nav/page.blp', 13 | 'components/navbar/button.blp', 14 | 'components/navbar/index.blp', 15 | 'components/navbar/title.blp', 16 | 'components/paginator.blp', 17 | 'components/player/now-playing/details/lyrics.blp', 18 | 'components/player/now-playing/details/queue.blp', 19 | 'components/player/now-playing/details/queueitem.blp', 20 | 'components/player/now-playing/details/related.blp', 21 | 'components/player/now-playing/counterpart-switcher.blp', 22 | 'components/player/now-playing/cover.blp', 23 | 'components/player/now-playing/sheet.blp', 24 | 'components/player/now-playing/volume-control.blp', 25 | 'components/player/video/controls.blp', 26 | 'components/player/video/view.blp', 27 | 'components/player/video/volume-controls.blp', 28 | 'components/player/full.blp', 29 | 'components/player/mini.blp', 30 | 'components/player/preview.blp', 31 | 'components/playlist/add-to-playlist-item.blp', 32 | 'components/playlist/bar.blp', 33 | 'components/playlist/edit.blp', 34 | 'components/playlist/header.blp', 35 | 'components/playlist/listitem.blp', 36 | 'components/playlist/save-to-playlist.blp', 37 | 'components/search/section.blp', 38 | 'components/search/topresult.blp', 39 | 'components/search/topresultsection.blp', 40 | 'gtk/help-overlay.blp', 41 | 'layout/sidebar.blp', 42 | 'layout/panes.blp', 43 | 'layout/shell.blp', 44 | 'pages/album.blp', 45 | 'pages/artist-albums.blp', 46 | 'pages/artist.blp', 47 | 'pages/authentication-error.blp', 48 | 'pages/channel.blp', 49 | 'pages/charts.blp', 50 | 'pages/channel-playlists.blp', 51 | 'pages/error.blp', 52 | 'pages/explore.blp', 53 | 'pages/home.blp', 54 | 'pages/login.blp', 55 | 'pages/mood-playlists.blp', 56 | 'pages/moods.blp', 57 | 'pages/new-releases.blp', 58 | 'pages/playlist.blp', 59 | 'pages/preferences.blp', 60 | 'pages/search.blp', 61 | 'window.blp', 62 | ] 63 | 64 | blueprints = custom_target('blueprints', 65 | input: blueprint_files, 66 | output: 'ui', 67 | command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'], 68 | ) 69 | 70 | ui_xml = '' 71 | 72 | foreach blueprint_file : blueprint_files 73 | ui_xml += 'ui/@0@\n '.format(blueprint_file.replace('.blp', '.ui')) 74 | endforeach 75 | -------------------------------------------------------------------------------- /data/ui/pages/artist-albums.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ArtistAlbumsPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: ScrolledWindow scrolled { 16 | hexpand: true; 17 | hscrollbar-policy: never; 18 | 19 | Adw.ClampScrollable { 20 | tightening-threshold: 1200; 21 | maximum-size: 1400; 22 | 23 | $CarouselGridView view { 24 | orientation: vertical; 25 | } 26 | } 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /data/ui/pages/authentication-error.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $AuthenticationErrorPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | Adw.StatusPage status { 16 | hexpand: true; 17 | icon-name: 'system-lock-screen-symbolic'; 18 | title: _("Authentication Is Required"); 19 | 20 | Box more { 21 | orientation: vertical; 22 | spacing: 6; 23 | 24 | Box buttons { 25 | halign: center; 26 | margin-bottom: 12; 27 | spacing: 6; 28 | 29 | Button { 30 | label: _("Log In"); 31 | action-name: 'win.login'; 32 | 33 | styles [ 34 | "pill", 35 | "suggested-action", 36 | ] 37 | } 38 | 39 | Button home_button { 40 | label: _("Go to Home"); 41 | 42 | styles [ 43 | "pill", 44 | ] 45 | } 46 | } 47 | 48 | Expander expander { 49 | label: _("Error Details"); 50 | halign: center; 51 | } 52 | 53 | Revealer { 54 | reveal-child: bind expander.expanded; 55 | 56 | Adw.Clamp { 57 | maximum-size: 1000; 58 | tightening-threshold: 600; 59 | 60 | Box { 61 | margin-top: 6; 62 | margin-bottom: 6; 63 | margin-end: 6; 64 | margin-start: 6; 65 | 66 | styles [ 67 | "card", 68 | ] 69 | 70 | TextView text_view { 71 | hexpand: true; 72 | top-margin: 12; 73 | bottom-margin: 12; 74 | left-margin: 12; 75 | right-margin: 12; 76 | wrap-mode: word_char; 77 | editable: false; 78 | 79 | styles [ 80 | "transparent", 81 | ] 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /data/ui/pages/channel-playlists.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ChannelPlaylistsPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: ScrolledWindow scrolled { 16 | hexpand: true; 17 | hscrollbar-policy: never; 18 | 19 | Adw.ClampScrollable { 20 | tightening-threshold: 1200; 21 | maximum-size: 1400; 22 | 23 | $CarouselGridView view { 24 | orientation: vertical; 25 | } 26 | } 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /data/ui/pages/channel.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ChannelPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | 14 | [end] 15 | MenuButton menu { 16 | icon-name: "view-more-symbolic"; 17 | } 18 | } 19 | 20 | content: Adw.BreakpointBin { 21 | width-request: 200; 22 | height-request: 200; 23 | 24 | Adw.Breakpoint { 25 | condition ("max-width: 700sp") 26 | 27 | setters { 28 | header.show_large_header: false; 29 | playlist_item_view.show-column-view: false; 30 | } 31 | } 32 | 33 | ScrolledWindow scrolled { 34 | vexpand: true; 35 | hexpand: true; 36 | hscrollbar-policy: never; 37 | 38 | Adw.Clamp { 39 | margin-top: 12; 40 | margin-bottom: 12; 41 | tightening-threshold: 1200; 42 | maximum-size: 1400; 43 | 44 | Box { 45 | spacing: 18; 46 | orientation: vertical; 47 | 48 | $PlaylistHeader header { 49 | show-large-header: true; 50 | show-meta: false; 51 | show-avatar: true; 52 | } 53 | 54 | Box songs_on_repeat { 55 | spacing: 12; 56 | orientation: vertical; 57 | height-request: 42; 58 | 59 | Box { 60 | margin-start: 12; 61 | margin-end: 12; 62 | 63 | Box { 64 | valign: center; 65 | orientation: vertical; 66 | 67 | Label subtitle { 68 | halign: start; 69 | ellipsize: end; 70 | label: _("Last 7 days"); 71 | 72 | styles [ 73 | "dim-label", 74 | ] 75 | } 76 | 77 | Label { 78 | label: _("Songs on Repeat"); 79 | halign: start; 80 | xalign: 0; 81 | 82 | styles [ 83 | "title-2", 84 | ] 85 | } 86 | } 87 | } 88 | 89 | $PlaylistItemView playlist_item_view { 90 | show-column-view: true; 91 | } 92 | } 93 | 94 | Box carousels { 95 | spacing: 12; 96 | orientation: vertical; 97 | } 98 | } 99 | } 100 | } 101 | }; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /data/ui/pages/charts.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ChartsPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: ScrolledWindow scrolled { 16 | vexpand: true; 17 | hexpand: true; 18 | 19 | Adw.Clamp { 20 | margin-top: 12; 21 | margin-bottom: 12; 22 | tightening-threshold: 1200; 23 | maximum-size: 1400; 24 | 25 | Box box { 26 | orientation: vertical; 27 | spacing: 12; 28 | 29 | DropDown drop_down { 30 | enable-search: true; 31 | margin-start: 12; 32 | margin-end: 12; 33 | halign: start; 34 | } 35 | } 36 | } 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /data/ui/pages/error.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ErrorPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | Adw.StatusPage status { 16 | hexpand: true; 17 | icon-name: 'dialog-question-symbolic'; 18 | title: _("An error occurred"); 19 | description: ''; 20 | 21 | Box more { 22 | orientation: vertical; 23 | spacing: 6; 24 | 25 | Expander expander { 26 | label: _("Error Details"); 27 | halign: center; 28 | } 29 | 30 | Revealer { 31 | reveal-child: bind expander.expanded; 32 | 33 | Adw.Clamp { 34 | maximum-size: 1000; 35 | tightening-threshold: 600; 36 | 37 | Box { 38 | margin-top: 6; 39 | margin-bottom: 6; 40 | margin-end: 6; 41 | margin-start: 6; 42 | 43 | styles [ 44 | "card", 45 | ] 46 | 47 | TextView text_view { 48 | hexpand: true; 49 | top-margin: 12; 50 | bottom-margin: 12; 51 | left-margin: 12; 52 | right-margin: 12; 53 | wrap-mode: word_char; 54 | editable: false; 55 | 56 | styles [ 57 | "transparent", 58 | ] 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /data/ui/pages/explore.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ExplorePage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: ScrolledWindow scrolled { 16 | vexpand: true; 17 | hexpand: true; 18 | 19 | Adw.Clamp { 20 | margin-top: 12; 21 | margin-bottom: 12; 22 | tightening-threshold: 1200; 23 | maximum-size: 1400; 24 | 25 | Box box { 26 | orientation: vertical; 27 | spacing: 12; 28 | 29 | FlowBox { 30 | column-spacing: 12; 31 | row-spacing: 12; 32 | homogeneous: true; 33 | margin-start: 12; 34 | margin-end: 12; 35 | max-children-per-line: 3; 36 | selection-mode: none; 37 | 38 | Button { 39 | action-name: "navigator.visit"; 40 | action-target: "\"muzika:new-releases\""; 41 | 42 | styles ["pill"] 43 | 44 | Adw.ButtonContent { 45 | icon-name: "moon-filled-symbolic"; 46 | label: _("New Releases"); 47 | } 48 | } 49 | 50 | Button { 51 | action-name: "navigator.visit"; 52 | action-target: "\"muzika:charts\""; 53 | 54 | styles ["pill"] 55 | 56 | Adw.ButtonContent { 57 | icon-name: "profit-symbolic"; 58 | label: _("Charts"); 59 | } 60 | } 61 | 62 | Button { 63 | action-name: "navigator.visit"; 64 | action-target: "\"muzika:moods-and-genres\""; 65 | 66 | styles ["pill"] 67 | 68 | Adw.ButtonContent { 69 | icon-name: "sentiment-satisfied-symbolic"; 70 | label: _("Moods and Genres"); 71 | } 72 | } 73 | } 74 | } 75 | } 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /data/ui/pages/home.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HomePage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | [top] 16 | ScrolledWindow { 17 | vscrollbar-policy: never; 18 | hscrollbar-policy: external; 19 | 20 | Adw.ToggleGroup moods { 21 | visible: false; 22 | margin-start: 12; 23 | margin-end: 12; 24 | margin-bottom: 6; 25 | margin-top: 6; 26 | can-shrink: false; 27 | halign: start; 28 | 29 | notify::active-name => $on_mood_changed_cb(); 30 | 31 | styles ["flat"] 32 | 33 | Adw.Toggle { 34 | name: "home"; 35 | label: _("Home"); 36 | } 37 | } 38 | } 39 | 40 | content: ScrolledWindow scrolled { 41 | vexpand: true; 42 | hexpand: true; 43 | 44 | Adw.Clamp { 45 | margin-top: 12; 46 | margin-bottom: 12; 47 | tightening-threshold: 1200; 48 | maximum-size: 1400; 49 | 50 | Box box { 51 | orientation: vertical; 52 | spacing: 12; 53 | 54 | Box carousels { 55 | orientation: vertical; 56 | spacing: 12; 57 | } 58 | 59 | $Paginator paginator { 60 | can-paginate: true; 61 | } 62 | } 63 | } 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /data/ui/pages/login.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $LoginDialog : Adw.Dialog { 5 | title: _("Login"); 6 | content-width: 360; 7 | content-height: 600; 8 | 9 | Adw.ToolbarView { 10 | [top] 11 | Adw.HeaderBar {} 12 | 13 | ScrolledWindow { 14 | Adw.ToastOverlay toast_overlay { 15 | Box { 16 | orientation: vertical; 17 | 18 | Stack stack { 19 | vexpand: true; 20 | 21 | Adw.Spinner spinner { 22 | halign: center; 23 | valign: center; 24 | width-request: 48; 25 | height-request: 48; 26 | } 27 | 28 | Box flow { 29 | margin-top: 24; 30 | margin-bottom: 24; 31 | margin-start: 14; 32 | margin-end: 14; 33 | spacing: 24; 34 | orientation: vertical; 35 | 36 | Picture qr { 37 | margin-top: 14; 38 | width-request: 150; 39 | height-request: 150; 40 | halign: center; 41 | styles ["br-9"] 42 | } 43 | 44 | Box title-1 { 45 | halign: center; 46 | orientation: vertical; 47 | margin-start: 12; 48 | 49 | Label { 50 | label: _("Scan with phone or go to"); 51 | } 52 | 53 | LinkButton link { 54 | styles ["title-3"] 55 | } 56 | } 57 | 58 | Box title-2 { 59 | halign: center; 60 | orientation: vertical; 61 | 62 | Label { 63 | label: _("Enter the code"); 64 | } 65 | 66 | Box { 67 | spacing: 6; 68 | halign: center; 69 | 70 | Label code { 71 | selectable: true; 72 | 73 | styles ["title-3"] 74 | } 75 | 76 | Button button { 77 | halign: center; 78 | icon-name: "edit-copy-symbolic"; 79 | tooltip-text: _("Copy Code"); 80 | 81 | styles ["flat"] 82 | } 83 | } 84 | } 85 | 86 | Button { 87 | label: _("Refresh Code"); 88 | halign: center; 89 | clicked => $refresh_cb(); 90 | 91 | styles ["pill"] 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /data/ui/pages/mood-playlists.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MoodPlaylistsPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: ScrolledWindow scrolled { 16 | vexpand: true; 17 | hexpand: true; 18 | 19 | Adw.Clamp { 20 | margin-top: 12; 21 | margin-bottom: 12; 22 | tightening-threshold: 1200; 23 | maximum-size: 1400; 24 | 25 | Box box { 26 | orientation: vertical; 27 | spacing: 12; 28 | } 29 | } 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /data/ui/pages/moods.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MoodsPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: ScrolledWindow scrolled { 16 | vexpand: true; 17 | hexpand: true; 18 | 19 | Adw.Clamp { 20 | margin-top: 12; 21 | margin-bottom: 12; 22 | tightening-threshold: 1200; 23 | maximum-size: 1400; 24 | 25 | Box box { 26 | orientation: vertical; 27 | spacing: 12; 28 | } 29 | } 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /data/ui/pages/new-releases.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $NewReleasesPage : Adw.Bin { 5 | Adw.ToolbarView { 6 | [top] 7 | Adw.HeaderBar { 8 | [start] 9 | Button { 10 | icon-name: "refresh"; 11 | action-name: "navigator.reload"; 12 | } 13 | } 14 | 15 | content: ScrolledWindow scrolled { 16 | vexpand: true; 17 | hexpand: true; 18 | 19 | Adw.Clamp { 20 | margin-top: 12; 21 | margin-bottom: 12; 22 | tightening-threshold: 1200; 23 | maximum-size: 1400; 24 | 25 | Box box { 26 | orientation: vertical; 27 | spacing: 12; 28 | } 29 | } 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /data/ui/pages/preferences.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaPreferencesDialog : Adw.PreferencesDialog { 5 | Adw.PreferencesPage sound_page { 6 | title: _("Sound"); 7 | icon-name: "multimedia-volume-control-symbolic"; 8 | 9 | Adw.PreferencesGroup { 10 | title: _("General"); 11 | 12 | Adw.SwitchRow background_play { 13 | title: _("Background Playback"); 14 | } 15 | 16 | Adw.SwitchRow inhibit_suspend { 17 | title: _("Inhibit Suspend"); 18 | subtitle: _("Request that the device does not suspend while playing"); 19 | } 20 | } 21 | 22 | Adw.PreferencesGroup { 23 | title: _("Preferred Playback Quality"); 24 | 25 | Adw.ComboRow video_quality { 26 | title: _("Video Quality"); 27 | } 28 | 29 | Adw.ComboRow audio_quality { 30 | title: _("Audio Quality"); 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /data/ui/window.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $MuzikaWindow : Adw.ApplicationWindow { 5 | default-width: 1000; 6 | default-height: 800; 7 | width-request: 360; 8 | height-request: 294; 9 | bottom-bar-height: bind $calculate_bottom_bar_height( 10 | main_stack.visible-child-name, 11 | shell.bottom-bar-height, 12 | video_player_view.bottom-bar-height 13 | ) as ; 14 | 15 | Adw.ToastOverlay toast_overlay { 16 | styles ["main-toast-overlay"] 17 | 18 | Stack main_stack { 19 | transition-type: slide_up_down; 20 | 21 | StackPage { 22 | name: "main"; 23 | child: $MuzikaShell shell { 24 | Adw.NavigationView navigation_view { 25 | vexpand: true; 26 | width-request: 300; 27 | } 28 | }; 29 | } 30 | 31 | StackPage { 32 | name: "video"; 33 | child: $VideoPlayerView video_player_view {}; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; 6 | 7 | export default tseslint.config( 8 | eslint.configs.recommended, 9 | ...tseslint.configs.strict, 10 | ...tseslint.configs.stylistic, 11 | eslintPluginPrettierRecommended, 12 | ); 13 | -------------------------------------------------------------------------------- /lib/build.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | 3 | import minimist from "minimist"; 4 | import { build } from "esbuild"; 5 | 6 | const args = minimist(process.argv.slice(2)); 7 | 8 | const USAGE = 9 | "Usage: build.js --out --cwd "; 10 | 11 | if (args._.length <= 0) throw new Error("No entry files provided \n" + USAGE); 12 | if (!(args.out || args.o)) throw new Error("No out dir specified \n" + USAGE); 13 | 14 | const OUT_DIR = args.out || args.o; 15 | const ENTRY_FILES = args._; 16 | const INIT_CWD = resolve( 17 | process.cwd(), 18 | args.cwd || process.env.INIT_CWD || ".", 19 | ); 20 | 21 | await build({ 22 | entryPoints: ENTRY_FILES.map((path) => resolve(INIT_CWD, path)), 23 | outdir: resolve(INIT_CWD, OUT_DIR), 24 | bundle: true, 25 | external: ["gi://*", "format", "gettext"], 26 | treeShaking: true, 27 | // esm 28 | format: "esm", 29 | }) 30 | .then(() => console.log("JS Build complete")); 31 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('muzika', 2 | version: 'nightly', 3 | meson_version: '>= 0.62.0', 4 | default_options: [ 'warning_level=2', 'werror=false', ], 5 | ) 6 | 7 | if get_option('profile') == 'development' 8 | profile = '.Devel' 9 | name_suffix = ' (Development)' 10 | vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: true).stdout().strip() 11 | if vcs_tag == '' 12 | version_suffix = '-devel' 13 | else 14 | version_suffix = '-@0@'.format (vcs_tag) 15 | endif 16 | else 17 | profile = '' 18 | name_suffix = '' 19 | version_suffix = '' 20 | endif 21 | 22 | 23 | base_name = 'com.vixalien.muzika' 24 | application_id = '@0@@1@'.format(base_name, profile) 25 | 26 | i18n = import('i18n') 27 | gnome = import('gnome') 28 | 29 | gettext_package = application_id 30 | muzika_prefix = get_option('prefix') 31 | muzika_bindir = muzika_prefix / get_option('bindir') 32 | muzika_libdir = muzika_prefix / get_option('libdir') 33 | muzika_datadir = muzika_prefix / get_option('datadir') 34 | muzika_pkgdatadir = muzika_datadir / application_id 35 | muzika_schemadir = muzika_datadir / 'glib-2.0' / 'schemas' 36 | 37 | gjs_dep = dependency('gjs-1.0', version: '>= 1.54.0') 38 | gjs_console = gjs_dep.get_variable(pkgconfig: 'gjs_console') 39 | yarn = find_program('yarn', required: true) 40 | 41 | subdir('data') 42 | subdir('src') 43 | subdir('po') 44 | 45 | gnome.post_install( 46 | glib_compile_schemas: true, 47 | gtk_update_icon_cache: true, 48 | update_desktop_database: true, 49 | ) 50 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: [ 5 | 'default', 6 | 'development' 7 | ], 8 | value: 'default' 9 | ) 10 | option( 11 | 'yarnrc', 12 | type: 'string' 13 | ) 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "muzika", 3 | "version": "1.0.0", 4 | "description": "An elegant music streaming app", 5 | "main": "src/main.ts", 6 | "type": "module", 7 | "repository": "https://github.com/vixalien/muzika", 8 | "author": "Angelo Verlain ", 9 | "license": "MIT", 10 | "dependencies": { 11 | "@lemaik/qrcode-svg": "^1.2.0", 12 | "libmuse": "^0.1.1", 13 | "lodash-es": "^4.17.21", 14 | "path-to-regexp": "^8.1.0", 15 | "web-streams-polyfill": "^4.0.0" 16 | }, 17 | "devDependencies": { 18 | "@eslint/js": "^9.11.0", 19 | "@types/core-js": "^2.5.8", 20 | "@types/eslint__js": "^8.42.3", 21 | "@types/lodash-es": "^4.17.12", 22 | "core-js": "^3.35.1", 23 | "esbuild": "^0.24.0", 24 | "eslint": "^9.11.0", 25 | "eslint-config-prettier": "^9.1.0", 26 | "eslint-plugin-prettier": "^5.1.3", 27 | "event-target-polyfill": "^0.0.4", 28 | "headers-polyfill": "^4.0.2", 29 | "minimist": "^1.2.8", 30 | "prettier": "3.3.3", 31 | "typescript": "^5.5.3", 32 | "typescript-eslint": "^8.6.0" 33 | }, 34 | "scripts": { 35 | "build": "node lib/build.js", 36 | "typecheck": "tsc --strict --noEmit", 37 | "generate-sources": "flatpak-node-generator yarn yarn.lock -o build-aux/flatpak/modules/yarn-deps-sources.json", 38 | "lint": "yarn eslint src" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | fr 2 | ja 3 | nl 4 | pt_BR 5 | tr 6 | zh_TW 7 | es 8 | -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/com.vixalien.muzika.desktop.in.in 2 | data/com.vixalien.muzika.metainfo.xml.in.in 3 | data/com.vixalien.muzika.gschema.xml.in 4 | 5 | data/ui/components/carousel/carousel.blp 6 | data/ui/components/navbar/index.blp 7 | data/ui/components/paginator.blp 8 | data/ui/components/player/full.blp 9 | data/ui/components/player/lyrics.blp 10 | data/ui/components/player/mini.blp 11 | data/ui/components/player/now-playing/details.blp 12 | data/ui/components/player/now-playing/view.blp 13 | data/ui/components/player/queue.blp 14 | data/ui/components/player/related.blp 15 | data/ui/components/playlist/edit.blp 16 | data/ui/components/playlist/header.blp 17 | data/ui/components/playlist/save-to-playlist.blp 18 | data/ui/components/search/section.blp 19 | data/ui/components/search/topresult.blp 20 | data/ui/components/search/topresultsection.blp 21 | data/ui/gtk/help-overlay.blp 22 | data/ui/pages/album.blp 23 | data/ui/pages/artist.blp 24 | data/ui/pages/authentication-error.blp 25 | data/ui/pages/channel.blp 26 | data/ui/pages/error.blp 27 | data/ui/pages/explore.blp 28 | data/ui/pages/login.blp 29 | data/ui/pages/playlist.blp 30 | data/ui/pages/preferences.blp 31 | data/ui/pages/search.blp 32 | data/ui/sidebar.blp 33 | 34 | src/components/carousel/card.ts 35 | src/components/carousel/flatcard.ts 36 | src/components/navbar/index.ts 37 | src/components/player/queue.ts 38 | src/components/player/video/util.ts 39 | src/components/player/video/volume-controls.ts 40 | src/components/playlist/bar.ts 41 | src/components/playlist/columnview.ts 42 | src/components/playlist/edit.ts 43 | src/components/playlist/listitem.ts 44 | src/components/playlist/save-to-playlist.ts 45 | src/components/search/section.ts 46 | src/components/search/topresultcard.ts 47 | src/pages.ts 48 | src/pages/album.ts 49 | src/pages/artist.ts 50 | src/pages/authentication-error.ts 51 | src/pages/channel.ts 52 | src/pages/charts.ts 53 | src/pages/error.ts 54 | src/pages/explore.ts 55 | src/pages/home.ts 56 | src/pages/library/base.ts 57 | src/pages/library/songs.ts 58 | src/pages/login.ts 59 | src/pages/playlist.ts 60 | src/pages/search.ts 61 | src/player/helpers.ts 62 | src/player/index.ts 63 | src/player/queue.ts 64 | src/sidebar.ts 65 | src/util/menu/like.ts 66 | src/util/language.ts 67 | src/util/portals.ts 68 | src/window.ts 69 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(application_id, preset: 'glib') 2 | -------------------------------------------------------------------------------- /src/com.vixalien.muzika.in: -------------------------------------------------------------------------------- 1 | #!@GJS@ -m 2 | 3 | import GLib from "gi://GLib"; 4 | import { exit } from "system"; 5 | 6 | imports.package.init({ 7 | name: "@APPLICATION_ID@", 8 | version: "@PACKAGE_VERSION@", 9 | prefix: "@prefix@", 10 | libdir: "@libdir@", 11 | datadir: "@datadir@", 12 | }); 13 | 14 | pkg.initGettext(); 15 | pkg.initFormat(); 16 | 17 | const loop = new GLib.MainLoop(null, false); 18 | import("resource://@resource_path@/js/main.js") 19 | .then((main) => { 20 | GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { 21 | loop.quit(); 22 | const exitCode = imports.package.run(main); 23 | exit(exitCode); 24 | return GLib.SOURCE_REMOVE; 25 | }); 26 | }) 27 | .catch(logError); 28 | loop.run(); 29 | -------------------------------------------------------------------------------- /src/com.vixalien.muzika.src.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | main.js 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/carousel/view/list.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import type { MixedItem } from "libmuse"; 5 | 6 | import { CarouselCard } from "../card.js"; 7 | import { MixedCardItem } from "src/components/library/mixedcard.js"; 8 | import { PlayableContainer, PlayableList } from "src/util/playablelist.js"; 9 | import { mixed_card_activate_cb } from "./util.js"; 10 | 11 | export type RequiredMixedItem = NonNullable; 12 | 13 | export class CarouselListView extends Gtk.ListView { 14 | static { 15 | GObject.registerClass( 16 | { 17 | GTypeName: "CarouselListView", 18 | }, 19 | this, 20 | ); 21 | } 22 | 23 | items = new PlayableList(); 24 | 25 | constructor() { 26 | super({ 27 | single_click_activate: true, 28 | margin_bottom: 18, 29 | orientation: Gtk.Orientation.HORIZONTAL, 30 | }); 31 | 32 | this.connect("activate", mixed_card_activate_cb.bind(this)); 33 | 34 | this.add_css_class("transparent"); 35 | this.add_css_class("carousel-list-view"); 36 | 37 | const factory = Gtk.SignalListItemFactory.new(); 38 | factory.connect("bind", this.bind_cb.bind(this)); 39 | factory.connect("setup", this.setup_cb.bind(this)); 40 | factory.connect("unbind", this.unbind_cb.bind(this)); 41 | 42 | this.factory = factory; 43 | this.model = Gtk.NoSelection.new(this.items); 44 | } 45 | 46 | setup_cb(_factory: Gtk.ListItemFactory, list_item: Gtk.ListItem) { 47 | const card = new CarouselCard(); 48 | list_item.set_child(card); 49 | } 50 | 51 | bind_cb(_factory: Gtk.ListItemFactory, list_item: Gtk.ListItem) { 52 | const card = list_item.child as CarouselCard; 53 | const container = list_item.item as PlayableContainer; 54 | 55 | if (container.object) { 56 | card.show_item(container.object); 57 | 58 | (container as ContainerWithBinding).binding = container.bind_property( 59 | "state", 60 | card, 61 | "state", 62 | GObject.BindingFlags.SYNC_CREATE, 63 | ); 64 | } 65 | } 66 | 67 | unbind_cb(_factory: Gtk.ListItemFactory, list_item: Gtk.ListItem) { 68 | const container = list_item.item as PlayableContainer; 69 | 70 | ((container as ContainerWithBinding).binding as GObject.Binding)?.unbind(); 71 | } 72 | 73 | vfunc_map(): void { 74 | this.items.setup_listeners(); 75 | super.vfunc_map(); 76 | } 77 | 78 | vfunc_unmap(): void { 79 | this.items.clear_listeners(); 80 | super.vfunc_unmap(); 81 | } 82 | } 83 | 84 | type ContainerWithBinding = PlayableContainer & { 85 | binding: GObject.Binding; 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/carousel/view/mood.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Adw from "gi://Adw"; 4 | 5 | import type { ParsedMoodOrGenre } from "libmuse"; 6 | 7 | import { PlayableContainer, PlayableList } from "src/util/playablelist.js"; 8 | import { mood_activate_cb } from "./util"; 9 | 10 | export class MoodBox extends Adw.Bin { 11 | static { 12 | GObject.registerClass( 13 | { 14 | GTypeName: "MoodBox", 15 | Template: 16 | "resource:///com/vixalien/muzika/ui/components/carousel/moodbox.ui", 17 | InternalChildren: ["label"], 18 | }, 19 | this, 20 | ); 21 | } 22 | 23 | private _label!: Gtk.Label; 24 | 25 | show_mood(mood: ParsedMoodOrGenre) { 26 | this._label.label = mood.title; 27 | 28 | const provider = Gtk.CssProvider.new(); 29 | // provider.load_from_data( 30 | // `.mood-box { border-left: 8px inset ${mood.color}; }`, 31 | // -1, 32 | // ); 33 | provider.load_from_data( 34 | `.mood-box { background-color: alpha(${mood.color}, .3); }`, 35 | -1, 36 | ); 37 | 38 | this.get_style_context().add_provider( 39 | provider, 40 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, 41 | ); 42 | } 43 | } 44 | 45 | export class CarouselMoodView extends Gtk.GridView { 46 | static { 47 | GObject.registerClass( 48 | { 49 | GTypeName: "CarouselMoodView", 50 | }, 51 | this, 52 | ); 53 | } 54 | 55 | items = new PlayableList(); 56 | 57 | constructor() { 58 | super({ 59 | single_click_activate: true, 60 | margin_bottom: 18, 61 | min_columns: 4, 62 | max_columns: 4, 63 | orientation: Gtk.Orientation.HORIZONTAL, 64 | }); 65 | 66 | this.connect("activate", mood_activate_cb.bind(this)); 67 | 68 | this.add_css_class("transparent"); 69 | this.add_css_class("carousel-mood-view"); 70 | 71 | const factory = Gtk.SignalListItemFactory.new(); 72 | factory.connect("bind", this.bind_cb.bind(this)); 73 | factory.connect("setup", this.setup_cb.bind(this)); 74 | 75 | this.factory = factory; 76 | this.model = Gtk.NoSelection.new(this.items); 77 | } 78 | 79 | setup_cb(_factory: Gtk.ListItemFactory, list_item: Gtk.ListItem) { 80 | const card = new MoodBox(); 81 | list_item.set_child(card); 82 | } 83 | 84 | bind_cb(_factory: Gtk.ListItemFactory, list_item: Gtk.ListItem) { 85 | const moodbox = list_item.child as MoodBox; 86 | const container = list_item.item as PlayableContainer; 87 | 88 | if (container.object) { 89 | moodbox.show_mood(container.object); 90 | } 91 | } 92 | 93 | vfunc_map(): void { 94 | this.items.setup_listeners(); 95 | super.vfunc_map(); 96 | } 97 | 98 | vfunc_unmap(): void { 99 | this.items.clear_listeners(); 100 | super.vfunc_unmap(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/components/library/mixedcard.ts: -------------------------------------------------------------------------------- 1 | import { RequiredMixedItem } from "../carousel"; 2 | 3 | import type { ParsedLibraryArtist } from "libmuse"; 4 | 5 | export type MixedCardItem = RequiredMixedItem | ParsedLibraryArtist; 6 | -------------------------------------------------------------------------------- /src/components/loading.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | export class Loading extends Gtk.Box { 5 | static { 6 | GObject.registerClass( 7 | { 8 | GTypeName: "Loading", 9 | Template: "resource:///com/vixalien/muzika/ui/components/loading.ui", 10 | Properties: { 11 | loading: GObject.ParamSpec.boolean( 12 | "loading", 13 | "Loading", 14 | "Whether the loading spinner is visible", 15 | GObject.ParamFlags.READWRITE, 16 | false, 17 | ), 18 | }, 19 | }, 20 | this, 21 | ); 22 | } 23 | 24 | private _loading = false; 25 | 26 | get loading() { 27 | return this._loading; 28 | } 29 | 30 | set loading(val: boolean) { 31 | this._loading = val; 32 | this.set_visible(val); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/maxheight.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | export class MuzikaMaxHeight extends Gtk.Widget { 5 | static { 6 | GObject.registerClass( 7 | { 8 | GTypeName: "MuzikaMaxHeight", 9 | Properties: { 10 | child: GObject.param_spec_object( 11 | "child", 12 | "Child", 13 | "The displayed child", 14 | Gtk.Widget.$gtype, 15 | GObject.ParamFlags.READWRITE, 16 | ), 17 | }, 18 | Implements: [Gtk.Buildable], 19 | }, 20 | this, 21 | ); 22 | } 23 | 24 | private _child?: Gtk.Widget; 25 | 26 | get child() { 27 | return this._child; 28 | } 29 | 30 | set child(value: Gtk.Widget | undefined) { 31 | if (this.child) this.child.unparent(); 32 | 33 | if (!value) return; 34 | 35 | this._child = value; 36 | value.set_parent(this); 37 | this.queue_resize(); 38 | } 39 | 40 | vfunc_size_allocate(width: number, height: number, baseline: number): void { 41 | if (!this.child) return; 42 | 43 | this.child.allocate(width, height, baseline, null); 44 | } 45 | 46 | vfunc_measure( 47 | orientation: Gtk.Orientation, 48 | for_size: number, 49 | ): [number, number, number, number] { 50 | if (!this.child) return super.vfunc_measure(orientation, for_size); 51 | 52 | const measured = this.child.vfunc_measure(orientation, for_size); 53 | 54 | if (orientation === Gtk.Orientation.VERTICAL) { 55 | measured[1] = 9999; 56 | } 57 | 58 | return [measured[0], measured[1], -1, -1]; 59 | } 60 | 61 | vfunc_snapshot(snapshot: Gtk.Snapshot): void { 62 | if (!this.child) return; 63 | 64 | this.snapshot_child(this.child, snapshot); 65 | } 66 | 67 | vfunc_add_child( 68 | builder: Gtk.Builder, 69 | child: GObject.Object, 70 | type?: string | null | undefined, 71 | ): void { 72 | if (child instanceof Gtk.Widget) { 73 | this.child = child; 74 | return; 75 | } 76 | 77 | super.vfunc_add_child(builder, child, type); 78 | } 79 | 80 | vfunc_get_request_mode() { 81 | return Gtk.SizeRequestMode.WIDTH_FOR_HEIGHT; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/navbar/button.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | import Gtk from "gi://Gtk?version=4.0"; 3 | 4 | export class NavbarButton extends Gtk.Box { 5 | static { 6 | GObject.registerClass( 7 | { 8 | GTypeName: "NavbarButton", 9 | Template: 10 | "resource:///com/vixalien/muzika/ui/components/navbar/button.ui", 11 | InternalChildren: ["image", "label"], 12 | Properties: { 13 | "icon-name": GObject.ParamSpec.string( 14 | "icon-name", 15 | "Icon name", 16 | "The icon name", 17 | GObject.ParamFlags.READWRITE, 18 | "", 19 | ), 20 | label: GObject.ParamSpec.string( 21 | "label", 22 | "Label", 23 | "The label", 24 | GObject.ParamFlags.READWRITE, 25 | "", 26 | ), 27 | link: GObject.ParamSpec.string( 28 | "link", 29 | "Link", 30 | "The link", 31 | GObject.ParamFlags.READWRITE, 32 | "", 33 | ), 34 | title: GObject.ParamSpec.string( 35 | "title", 36 | "Title", 37 | "Header title", 38 | GObject.ParamFlags.READWRITE, 39 | "", 40 | ), 41 | "requires-login": GObject.ParamSpec.boolean( 42 | "requires-login", 43 | "Requires login", 44 | "Whether this button is only shown when logged in", 45 | GObject.ParamFlags.READWRITE, 46 | false, 47 | ), 48 | }, 49 | }, 50 | this, 51 | ); 52 | } 53 | 54 | _image!: Gtk.Image; 55 | _label!: Gtk.Label; 56 | 57 | link: string | null = null; 58 | title: string | null = null; 59 | 60 | get icon_name(): string { 61 | return this._image.icon_name; 62 | } 63 | 64 | set icon_name(name: string) { 65 | this._image.icon_name = name; 66 | } 67 | 68 | get label(): string { 69 | return this._label.label; 70 | } 71 | 72 | set label(label: string) { 73 | this._label.label = label; 74 | 75 | this.has_tooltip = this._label.get_layout().is_ellipsized(); 76 | this.tooltip_text = label; 77 | } 78 | 79 | show_button(button: NavbarButtonContructorProperties) { 80 | this.icon_name = button.icon_name; 81 | this.label = button.label; 82 | } 83 | } 84 | 85 | export interface NavbarButtonContructorProperties { 86 | icon_name: string; 87 | link: string; 88 | requires_login?: boolean; 89 | label: string; 90 | title?: string; 91 | pinned?: boolean; 92 | } 93 | -------------------------------------------------------------------------------- /src/components/navbar/title.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | import Gtk from "gi://Gtk?version=4.0"; 3 | 4 | export class NavbarTitle extends Gtk.Box { 5 | static { 6 | GObject.registerClass( 7 | { 8 | GTypeName: "NavbarTitle", 9 | Template: 10 | "resource:///com/vixalien/muzika/ui/components/navbar/title.ui", 11 | InternalChildren: ["label", "action"], 12 | Implements: [Gtk.Buildable], 13 | Properties: { 14 | action: GObject.ParamSpec.object( 15 | "action", 16 | "Action", 17 | "Action widget", 18 | GObject.ParamFlags.READWRITE, 19 | Gtk.Widget.$gtype, 20 | ), 21 | label: GObject.ParamSpec.string( 22 | "label", 23 | "Label", 24 | "Section Label", 25 | GObject.ParamFlags.READWRITE, 26 | "", 27 | ), 28 | }, 29 | }, 30 | this, 31 | ); 32 | } 33 | 34 | private _label!: Gtk.Label; 35 | private _action!: Gtk.Box; 36 | 37 | get action(): Gtk.Widget | null { 38 | return this._action.get_first_child(); 39 | } 40 | 41 | set action(action: Gtk.Widget | null) { 42 | const first = this._action.get_first_child(); 43 | 44 | if (first) { 45 | this._action.remove(first); 46 | } 47 | 48 | if (action) { 49 | this._action.append(action); 50 | } 51 | } 52 | 53 | get label(): string { 54 | return this._label.label; 55 | } 56 | 57 | set label(title: string) { 58 | this._label.label = title; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/paginator.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | export class Paginator extends Gtk.Revealer { 5 | static { 6 | GObject.registerClass( 7 | { 8 | GTypeName: "Paginator", 9 | Template: "resource:///com/vixalien/muzika/ui/components/paginator.ui", 10 | InternalChildren: ["stack", "button", "spinner"], 11 | Signals: { 12 | activate: {}, 13 | }, 14 | Properties: { 15 | loading: GObject.ParamSpec.boolean( 16 | "loading", 17 | "Loading", 18 | "Whether the button is loading", 19 | GObject.ParamFlags.READWRITE, 20 | false, 21 | ), 22 | "can-paginate": GObject.ParamSpec.boolean( 23 | "can-paginate", 24 | "Can Paginate", 25 | "Whether the content can be paginated and the paginator is visible", 26 | GObject.ParamFlags.READWRITE, 27 | false, 28 | ), 29 | }, 30 | }, 31 | this, 32 | ); 33 | } 34 | 35 | _stack!: Gtk.Stack; 36 | _button!: Gtk.Button; 37 | /// @ts-expect-error outdated types 38 | _spinner!: Adw.Spinner; 39 | 40 | private _loading = false; 41 | 42 | get loading() { 43 | return this._loading; 44 | } 45 | 46 | set loading(value: boolean) { 47 | this._loading = value; 48 | 49 | if (value) { 50 | this._stack.visible_child = this._spinner; 51 | } else { 52 | this._stack.visible_child = this._button; 53 | } 54 | } 55 | 56 | get can_paginate() { 57 | return this.reveal_child; 58 | } 59 | 60 | set can_paginate(value: boolean) { 61 | this.reveal_child = value; 62 | } 63 | 64 | constructor() { 65 | super({ 66 | reveal_child: false, 67 | }); 68 | } 69 | 70 | private on_button_clicked() { 71 | this.loading = true; 72 | this.emit("activate"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/player/mini.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import { MuzikaPlayer } from "src/player"; 5 | import { SignalListeners } from "src/util/signal-listener.js"; 6 | import { get_player } from "src/application.js"; 7 | import { bind_play_icon } from "src/player/helpers.js"; 8 | import { PlayerProgressBar } from "./progress"; 9 | import { pretty_subtitles } from "src/util/text"; 10 | 11 | GObject.type_ensure(PlayerProgressBar.$gtype); 12 | 13 | export class MiniPlayerView extends Gtk.Overlay { 14 | static { 15 | GObject.registerClass( 16 | { 17 | GTypeName: "MiniPlayerView", 18 | Template: 19 | "resource:///com/vixalien/muzika/ui/components/player/mini.ui", 20 | InternalChildren: ["title", "subtitle", "play_button"], 21 | }, 22 | this, 23 | ); 24 | } 25 | 26 | private _title!: Gtk.Label; 27 | private _subtitle!: Gtk.Label; 28 | private _play_button!: Gtk.Button; 29 | 30 | player: MuzikaPlayer; 31 | 32 | constructor() { 33 | super(); 34 | 35 | this.player = get_player(); 36 | 37 | // we can't put this in `setup_player` because that method is only ever 38 | // called on map, and invisible widgets can't be mapped 39 | // @ts-expect-error incorrect types 40 | this.player.queue.bind_property_full( 41 | "current", 42 | this, 43 | "visible", 44 | GObject.BindingFlags.SYNC_CREATE, 45 | (_, from) => { 46 | return [true, !!from]; 47 | }, 48 | null, 49 | ); 50 | } 51 | 52 | private listeners = new SignalListeners(); 53 | 54 | setup_player() { 55 | const player = get_player(); 56 | 57 | this.listeners.add_bindings( 58 | bind_play_icon(this._play_button), 59 | // @ts-expect-error incorrect types 60 | player.queue.bind_property_full( 61 | "current", 62 | this._title, 63 | "label", 64 | GObject.BindingFlags.SYNC_CREATE, 65 | () => { 66 | const track = player.queue.current?.object; 67 | if (!track) return [false, null]; 68 | 69 | return [true, track.title]; 70 | }, 71 | null, 72 | ), 73 | // @ts-expect-error incorrect types 74 | player.queue.bind_property_full( 75 | "current", 76 | this._subtitle, 77 | "label", 78 | GObject.BindingFlags.SYNC_CREATE, 79 | () => { 80 | const track = player.queue.current?.object; 81 | if (!track) return [false, null]; 82 | 83 | return [true, pretty_subtitles(track.artists).plain]; 84 | }, 85 | null, 86 | ), 87 | ); 88 | } 89 | 90 | vfunc_map(): void { 91 | this.listeners.clear(); 92 | this.setup_player(); 93 | super.vfunc_map(); 94 | } 95 | 96 | vfunc_unmap(): void { 97 | this.listeners.clear(); 98 | super.vfunc_unmap(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/components/player/now-playing/counterpart-switcher.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | import Adw from "gi://Adw"; 3 | 4 | import { get_player } from "src/application"; 5 | import { SignalListeners } from "src/util/signal-listener"; 6 | import { ObjectContainer } from "src/util/objectcontainer"; 7 | import { QueueTrack } from "libmuse"; 8 | 9 | export class MuzikaNPCounterpartSwitcher extends Adw.Bin { 10 | static { 11 | GObject.registerClass( 12 | { 13 | GTypeName: "MuzikaNPCounterpartSwitcher", 14 | Template: 15 | "resource:///com/vixalien/muzika/ui/components/player/now-playing/counterpart-switcher.ui", 16 | InternalChildren: ["toggle_group"], 17 | }, 18 | this, 19 | ); 20 | } 21 | 22 | /// @ts-expect-error outdated types 23 | private _toggle_group: Adw.ToggleGroup; 24 | private listeners = new SignalListeners(); 25 | 26 | vfunc_map(): void { 27 | super.vfunc_map(); 28 | 29 | this.listeners.clear(); 30 | 31 | this.listeners.add_bindings( 32 | get_player().queue.bind_property_full( 33 | "current-is-video", 34 | this._toggle_group, 35 | "active-name", 36 | GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL, 37 | (_, current_is_video: boolean) => { 38 | return [true, current_is_video ? "video" : "song"]; 39 | }, 40 | (_, active_name: "song" | "video") => { 41 | return [true, active_name === "video" ? true : false]; 42 | }, 43 | ), 44 | // @ts-expect-error invalid types 45 | get_player().queue.bind_property_full( 46 | "current", 47 | this._toggle_group, 48 | "sensitive", 49 | GObject.BindingFlags.SYNC_CREATE, 50 | (_, current: ObjectContainer) => { 51 | return [true, current.object.counterpart != null]; 52 | }, 53 | null, 54 | ), 55 | ); 56 | } 57 | 58 | vfunc_unmap(): void { 59 | super.vfunc_unmap(); 60 | 61 | this.listeners.clear(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/player/now-playing/details/queueitem.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import type { QueueTrack } from "libmuse"; 5 | 6 | import { pretty_subtitles } from "src/util/text.js"; 7 | import { setup_link_label } from "src/util/label.js"; 8 | import { MenuHelper } from "src/util/menu/index.js"; 9 | import { menuLikeRow } from "src/util/menu/like.js"; 10 | import { menuLibraryRow } from "src/util/menu/library.js"; 11 | import { load_thumbnails } from "src/components/webimage"; 12 | 13 | export class MuzikaNPQueueItem extends Gtk.Box { 14 | static { 15 | GObject.registerClass( 16 | { 17 | GTypeName: "MuzikaNPQueueItem", 18 | Template: 19 | "resource:///com/vixalien/muzika/ui/components/player/now-playing/details/queueitem.ui", 20 | InternalChildren: ["image", "title", "explicit", "subtitle"], 21 | }, 22 | this, 23 | ); 24 | } 25 | 26 | item?: QueueTrack; 27 | 28 | private _image!: Gtk.Image; 29 | private _title!: Gtk.Label; 30 | private _explicit!: Gtk.Image; 31 | private _subtitle!: Gtk.Label; 32 | 33 | private menu_helper: MenuHelper; 34 | 35 | constructor() { 36 | super({}); 37 | 38 | setup_link_label(this._subtitle); 39 | 40 | this.menu_helper = MenuHelper.new(this); 41 | } 42 | 43 | set_track(item: QueueTrack) { 44 | this.item = item; 45 | 46 | this._title.set_label(item.title); 47 | 48 | const subtitles = pretty_subtitles(item.artists); 49 | 50 | this._subtitle.set_markup(subtitles.markup); 51 | this._subtitle.tooltip_text = subtitles.plain; 52 | 53 | this._explicit.set_visible(item.isExplicit); 54 | 55 | load_thumbnails(this._image, item.thumbnails, 48); 56 | 57 | this.menu_helper.set_builder(() => { 58 | return [ 59 | menuLikeRow( 60 | item.likeStatus, 61 | item.videoId, 62 | (likeStatus) => (item.likeStatus = likeStatus), 63 | ), 64 | [_("Start radio"), `queue.play-song("${item.videoId}?radio=true")`], 65 | [_("Play next"), `queue.add-song("${item.videoId}?next=true")`], 66 | [_("Add to queue"), `queue.add-song("${item.videoId}")`], 67 | menuLibraryRow( 68 | item.feedbackTokens, 69 | (tokens) => (item.feedbackTokens = tokens), 70 | ), 71 | [_("Save to playlist"), `win.add-to-playlist("${item.videoId}")`], 72 | item.album 73 | ? [ 74 | _("Go to album"), 75 | `navigator.visit("muzika:album:${item.album.id}")`, 76 | ] 77 | : null, 78 | item.artists.length > 0 79 | ? [ 80 | _("Go to artist"), 81 | `navigator.visit("muzika:artist:${item.artists[0].id}")`, 82 | ] 83 | : null, 84 | ]; 85 | }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/components/player/progress.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import { SignalListeners } from "src/util/signal-listener"; 5 | import { get_player } from "src/application"; 6 | 7 | export class PlayerProgressBar extends Gtk.ProgressBar { 8 | static { 9 | GObject.registerClass( 10 | { 11 | GTypeName: "PlayerProgressBar", 12 | }, 13 | this, 14 | ); 15 | } 16 | 17 | constructor() { 18 | super({ 19 | valign: Gtk.Align.START, 20 | }); 21 | 22 | this.add_css_class("osd"); 23 | } 24 | 25 | private update_fraction() { 26 | const player = get_player(); 27 | 28 | this.fraction = 29 | Math.max( 30 | Math.min( 31 | (player.initial_seek_to ?? player.timestamp) / player.duration, 32 | 1, 33 | ), 34 | 0, 35 | ) || 0; 36 | 37 | const already_buffering = this.has_css_class("buffering"); 38 | 39 | if (player.is_buffering && player.playing) { 40 | if (!already_buffering) this.add_css_class("buffering"); 41 | } else if (already_buffering) { 42 | this.remove_css_class("buffering"); 43 | } 44 | } 45 | 46 | listeners = new SignalListeners(); 47 | 48 | vfunc_map() { 49 | super.vfunc_map(); 50 | this.listeners.clear(); 51 | this.listeners.connect( 52 | get_player(), 53 | "notify::timestamp", 54 | this.update_fraction.bind(this), 55 | ); 56 | this.listeners.connect( 57 | get_player(), 58 | "notify::duration", 59 | this.update_fraction.bind(this), 60 | ); 61 | this.listeners.connect( 62 | get_player(), 63 | "notify::is-buffering", 64 | this.update_fraction.bind(this), 65 | ); 66 | } 67 | 68 | vfunc_unmap() { 69 | super.vfunc_unmap(); 70 | this.listeners.clear(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/player/view.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | import Adw from "gi://Adw"; 3 | 4 | import { MiniPlayerView } from "./mini.js"; 5 | import { FullPlayerView } from "./full.js"; 6 | import { MuzikaPlayer } from "src/player"; 7 | 8 | export interface PlayerViewOptions { 9 | player: MuzikaPlayer; 10 | } 11 | 12 | export class PlayerView extends Adw.Bin { 13 | static { 14 | GObject.registerClass( 15 | { 16 | GTypeName: "PlayerView", 17 | }, 18 | this, 19 | ); 20 | } 21 | 22 | squeezer: Adw.Squeezer; 23 | mini: MiniPlayerView; 24 | full: FullPlayerView; 25 | 26 | constructor() { 27 | super(); 28 | 29 | this.squeezer = new Adw.Squeezer({ 30 | homogeneous: false, 31 | interpolate_size: true, 32 | transition_type: Adw.SqueezerTransitionType.CROSSFADE, 33 | }); 34 | 35 | this.full = new FullPlayerView(); 36 | this.mini = new MiniPlayerView(); 37 | 38 | this.squeezer.add(this.full); 39 | this.squeezer.add(this.mini); 40 | 41 | this.set_child(this.squeezer); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/playlist/add-to-playlist-item.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import type { AddToPlaylistItem } from "libmuse"; 5 | 6 | import { load_thumbnails } from "../webimage.js"; 7 | 8 | export class AddToPlaylistItemCard extends Gtk.Box { 9 | static { 10 | GObject.registerClass( 11 | { 12 | GTypeName: "AddToPlaylistItemCard", 13 | Template: 14 | "resource:///com/vixalien/muzika/ui/components/playlist/add-to-playlist-item.ui", 15 | InternalChildren: ["title", "subtitle", "image"], 16 | }, 17 | this, 18 | ); 19 | } 20 | 21 | item?: AddToPlaylistItem; 22 | 23 | private _title!: Gtk.Label; 24 | private _subtitle!: Gtk.Label; 25 | private _image!: Gtk.Image; 26 | 27 | show_item(item: AddToPlaylistItem) { 28 | this.item = item; 29 | 30 | this._title.label = item.title; 31 | this._subtitle.label = item.songs; 32 | 33 | load_thumbnails(this._image, item.thumbnails, this._image.pixel_size); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/playlist/columnview/columns/add.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | interface AddColumnButton extends Gtk.Button { 5 | listener?: number; 6 | } 7 | 8 | export class AddColumn extends Gtk.ColumnViewColumn { 9 | static { 10 | GObject.registerClass( 11 | { 12 | GTypeName: "AddColumn", 13 | Signals: { 14 | add: { param_types: [GObject.TYPE_INT] }, 15 | }, 16 | }, 17 | this, 18 | ); 19 | } 20 | 21 | constructor() { 22 | super({ 23 | visible: false, 24 | }); 25 | 26 | const factory = Gtk.SignalListItemFactory.new(); 27 | factory.connect("setup", this.setup_cb.bind(this)); 28 | factory.connect("bind", this.bind_cb.bind(this)); 29 | factory.connect("unbind", this.unbind_cb.bind(this)); 30 | 31 | this.factory = factory; 32 | } 33 | 34 | setup_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 35 | const button = new Gtk.Button({ 36 | icon_name: "list-add-symbolic", 37 | }); 38 | 39 | button.add_css_class("flat"); 40 | 41 | list_item.set_child(button); 42 | } 43 | 44 | bind_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 45 | const button = list_item.child as AddColumnButton; 46 | 47 | button.listener = button.connect("clicked", () => { 48 | this.emit("add", list_item.position); 49 | }); 50 | } 51 | 52 | unbind_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 53 | const button = list_item.child as AddColumnButton; 54 | 55 | if (button.listener) { 56 | button.disconnect(button.listener); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/playlist/columnview/columns/album.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Pango from "gi://Pango"; 4 | 5 | import { escape_label } from "src/util/text"; 6 | import { PlayableContainer } from "src/util/playablelist"; 7 | import { setup_link_label } from "src/util/label"; 8 | 9 | export class AlbumColumn extends Gtk.ColumnViewColumn { 10 | static { 11 | GObject.registerClass({ GTypeName: "AlbumColumn" }, this); 12 | } 13 | 14 | constructor() { 15 | super({ 16 | title: _("Album"), 17 | expand: true, 18 | }); 19 | 20 | const factory = Gtk.SignalListItemFactory.new(); 21 | factory.connect("setup", this.setup_cb.bind(this)); 22 | factory.connect("bind", this.bind_cb.bind(this)); 23 | 24 | this.factory = factory; 25 | } 26 | 27 | setup_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 28 | const label = new Gtk.Label({ 29 | hexpand: true, 30 | ellipsize: Pango.EllipsizeMode.END, 31 | xalign: 0, 32 | css_classes: ["flat-links", "dim-label"], 33 | }); 34 | 35 | setup_link_label(label); 36 | 37 | list_item.set_child(label); 38 | } 39 | 40 | bind_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 41 | const label = list_item.child as Gtk.Label; 42 | const container = list_item.item as PlayableContainer; 43 | 44 | const playlist_item = container.object; 45 | 46 | if (playlist_item.album) { 47 | if (playlist_item.album.id) { 48 | label.set_markup( 49 | `${escape_label( 50 | playlist_item.album.name, 51 | )}`, 52 | ); 53 | } else { 54 | label.use_markup = false; 55 | label.label = playlist_item.album.name; 56 | } 57 | 58 | label.tooltip_text = playlist_item.album.name; 59 | } else { 60 | label.label = ""; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/playlist/columnview/columns/artist.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Pango from "gi://Pango"; 4 | 5 | import { pretty_subtitles } from "src/util/text"; 6 | import { PlayableContainer } from "src/util/playablelist"; 7 | import { setup_link_label } from "src/util/label"; 8 | 9 | export class ArtistColumn extends Gtk.ColumnViewColumn { 10 | static { 11 | GObject.registerClass({ GTypeName: "ArtistColumn" }, this); 12 | } 13 | 14 | constructor() { 15 | super({ 16 | title: _("Artist"), 17 | expand: true, 18 | }); 19 | 20 | const factory = Gtk.SignalListItemFactory.new(); 21 | factory.connect("setup", this.setup_cb.bind(this)); 22 | factory.connect("bind", this.bind_cb.bind(this)); 23 | 24 | this.factory = factory; 25 | } 26 | 27 | setup_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 28 | const label = new Gtk.Label({ 29 | hexpand: true, 30 | ellipsize: Pango.EllipsizeMode.END, 31 | xalign: 0, 32 | css_classes: ["flat-links", "dim-label"], 33 | }); 34 | 35 | setup_link_label(label); 36 | 37 | list_item.set_child(label); 38 | } 39 | 40 | bind_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 41 | const label = list_item.child as Gtk.Label; 42 | const container = list_item.item as PlayableContainer; 43 | 44 | const playlist_item = container.object; 45 | 46 | const subtitle = pretty_subtitles(playlist_item.artists); 47 | 48 | label.set_markup(subtitle.markup); 49 | label.tooltip_text = subtitle.plain; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/playlist/columnview/columns/chart-rank.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import { PlayableContainer } from "src/util/playablelist"; 5 | 6 | class ChartRankBox extends Gtk.Box { 7 | static { 8 | GObject.registerClass({ GTypeName: "ChartRankBox" }, this); 9 | } 10 | 11 | private _rank = new Gtk.Label(); 12 | private _change = new Gtk.Image(); 13 | private _container = new Gtk.Box({ 14 | valign: Gtk.Align.CENTER, 15 | hexpand: true, 16 | halign: Gtk.Align.CENTER, 17 | spacing: 6, 18 | }); 19 | 20 | get rank() { 21 | return this._rank.label; 22 | } 23 | 24 | set rank(value: string) { 25 | this._rank.label = value; 26 | } 27 | 28 | get icon_name() { 29 | return this._change.icon_name; 30 | } 31 | 32 | set icon_name(value: string) { 33 | this._change.icon_name = value; 34 | } 35 | 36 | constructor() { 37 | super({ 38 | width_request: 36, 39 | }); 40 | 41 | this._rank.add_css_class("dim-label"); 42 | 43 | this._container.append(this._rank); 44 | this._container.append(this._change); 45 | 46 | this.append(this._container); 47 | } 48 | } 49 | 50 | export class ChartRankColumn extends Gtk.ColumnViewColumn { 51 | static { 52 | GObject.registerClass({ GTypeName: "ChartRankColumn" }, this); 53 | } 54 | 55 | constructor() { 56 | super({ 57 | visible: false, 58 | }); 59 | 60 | const factory = Gtk.SignalListItemFactory.new(); 61 | factory.connect("setup", this.setup_cb.bind(this)); 62 | factory.connect("bind", this.bind_cb.bind(this)); 63 | 64 | this.factory = factory; 65 | } 66 | 67 | setup_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 68 | const box = new ChartRankBox(); 69 | 70 | list_item.set_child(box); 71 | } 72 | 73 | bind_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 74 | const box = list_item.child as ChartRankBox; 75 | const container = list_item.item as PlayableContainer; 76 | 77 | const playlist_item = container.object; 78 | 79 | if (playlist_item.rank) { 80 | box.rank = playlist_item.rank; 81 | 82 | switch (playlist_item.change) { 83 | case "DOWN": 84 | box.icon_name = "trend-down-symbolic"; 85 | break; 86 | case "UP": 87 | box.icon_name = "trend-up-symbolic"; 88 | break; 89 | default: 90 | box.icon_name = "trend-neutral-symbolic"; 91 | break; 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/components/playlist/columnview/columns/duration.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import { PlayableContainer } from "src/util/playablelist"; 5 | 6 | export class DurationColumn extends Gtk.ColumnViewColumn { 7 | static { 8 | GObject.registerClass({ GTypeName: "DurationColumn" }, this); 9 | } 10 | 11 | constructor() { 12 | super({ 13 | title: _("Time"), 14 | }); 15 | 16 | const factory = Gtk.SignalListItemFactory.new(); 17 | factory.connect("setup", this.setup_cb.bind(this)); 18 | factory.connect("bind", this.bind_cb.bind(this)); 19 | 20 | this.factory = factory; 21 | } 22 | 23 | setup_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 24 | const label = new Gtk.Label({ 25 | xalign: 1, 26 | halign: Gtk.Align.END, 27 | css_classes: ["dim-label", "numeric"], 28 | }); 29 | 30 | list_item.set_child(label); 31 | } 32 | 33 | bind_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 34 | const label = list_item.child as Gtk.Label; 35 | const container = list_item.item as PlayableContainer; 36 | 37 | const playlist_item = container.object; 38 | 39 | label.label = playlist_item.duration ?? ""; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/playlist/columnview/columns/title.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Pango from "gi://Pango"; 4 | 5 | import { PlayableContainer } from "src/util/playablelist"; 6 | 7 | class TitleBox extends Gtk.Box { 8 | static { 9 | GObject.registerClass({ GTypeName: "TitleBox" }, this); 10 | } 11 | 12 | label: Gtk.Label; 13 | explicit: Gtk.Image; 14 | 15 | constructor() { 16 | super({ 17 | spacing: 6, 18 | }); 19 | 20 | this.label = new Gtk.Label({ 21 | hexpand: true, 22 | ellipsize: Pango.EllipsizeMode.END, 23 | xalign: 0, 24 | }); 25 | 26 | this.explicit = new Gtk.Image({ 27 | valign: Gtk.Align.CENTER, 28 | icon_name: "explicit-symbolic", 29 | css_classes: ["dim-label"], 30 | }); 31 | 32 | this.append(this.label); 33 | this.append(this.explicit); 34 | } 35 | } 36 | 37 | export class TitleColumn extends Gtk.ColumnViewColumn { 38 | static { 39 | GObject.registerClass({ GTypeName: "TitleColumn" }, this); 40 | } 41 | 42 | constructor() { 43 | super({ 44 | title: _("Song"), 45 | expand: true, 46 | }); 47 | 48 | const factory = Gtk.SignalListItemFactory.new(); 49 | factory.connect("setup", this.setup_cb.bind(this)); 50 | factory.connect("bind", this.bind_cb.bind(this)); 51 | 52 | this.factory = factory; 53 | } 54 | 55 | setup_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 56 | const title = new TitleBox(); 57 | 58 | list_item.set_child(title); 59 | } 60 | 61 | bind_cb(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) { 62 | const title = list_item.child as TitleBox; 63 | const container = list_item.item as PlayableContainer; 64 | 65 | const playlist_item = container.object; 66 | 67 | title.label.tooltip_text = title.label.label = playlist_item.title; 68 | title.explicit.visible = playlist_item.isExplicit; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/components/search/section.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import GLib from "gi://GLib"; 4 | 5 | import { filters, search } from "libmuse"; 6 | import type { SearchContent, SearchResults } from "libmuse"; 7 | 8 | import { search_args_to_url } from "../../pages/search.js"; 9 | import { FlatListView } from "../carousel/view/flatlist.js"; 10 | import { PlayableContainer } from "src/util/playablelist.js"; 11 | 12 | GObject.type_ensure(FlatListView.$gtype); 13 | 14 | export class SearchSection extends Gtk.Box { 15 | static { 16 | GObject.registerClass( 17 | { 18 | GTypeName: "SearchSection", 19 | Template: 20 | "resource:///com/vixalien/muzika/ui/components/search/section.ui", 21 | InternalChildren: ["title", "more", "card_view"], 22 | }, 23 | this, 24 | ); 25 | } 26 | 27 | private _title!: Gtk.Label; 28 | private _more!: Gtk.Button; 29 | private _card_view!: FlatListView; 30 | 31 | args: Parameters; 32 | show_more: boolean; 33 | show_type: boolean; 34 | 35 | constructor(options: { 36 | args: Parameters; 37 | show_more?: boolean; 38 | show_type?: boolean; 39 | }) { 40 | super(); 41 | 42 | this.args = options.args; 43 | this.show_more = options.show_more ?? false; 44 | this.show_type = options.show_type ?? true; 45 | } 46 | 47 | set_category(category: SearchResults["categories"][0]) { 48 | this._title.label = category.title || _("Results"); 49 | 50 | if ( 51 | category.results.length >= 0 && 52 | this.show_more && 53 | category.filter && 54 | filters.includes(category.filter) 55 | ) { 56 | const url = search_args_to_url(this.args[0], { 57 | filter: category.filter ?? undefined, 58 | ...this.args[1], 59 | }); 60 | 61 | this._more.visible = true; 62 | this._more.action_name = "navigator.visit"; 63 | this._more.action_target = GLib.Variant.new("s", url); 64 | } 65 | 66 | this.add_search_contents(category.results); 67 | } 68 | 69 | add_search_contents(search_contents: SearchContent[]) { 70 | this._card_view.items.splice( 71 | this._card_view.items.n_items, 72 | 0, 73 | search_contents.map(PlayableContainer.new_from_search_content), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/layout/panes.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | import Gtk from "gi://Gtk?version=4.0"; 3 | import Adw from "gi://Adw"; 4 | import { get_navigator } from "src/navigation"; 5 | import { SignalListeners } from "src/util/signal-listener"; 6 | 7 | export class MuzikaPanes extends Adw.BreakpointBin { 8 | static { 9 | GObject.registerClass( 10 | { 11 | GTypeName: "MuzikaPanes", 12 | Template: "resource:///com/vixalien/muzika/ui/layout/panes.ui", 13 | InternalChildren: ["sidebar_page", "content_page", "split_view"], 14 | Properties: { 15 | sidebar: GObject.param_spec_object( 16 | "sidebar", 17 | "Sidebar", 18 | "The widget to show as the sidebar", 19 | Gtk.Widget.$gtype, 20 | GObject.ParamFlags.READWRITE, 21 | ), 22 | content: GObject.param_spec_object( 23 | "content", 24 | "content", 25 | "The widget to show as the content", 26 | Gtk.Widget.$gtype, 27 | GObject.ParamFlags.READWRITE, 28 | ), 29 | }, 30 | Implements: [Gtk.Buildable], 31 | }, 32 | this, 33 | ); 34 | } 35 | 36 | private _split_view!: Adw.NavigationSplitView; 37 | private _sidebar_page!: Adw.NavigationPage; 38 | private _content_page!: Adw.NavigationPage; 39 | 40 | private sidebar!: Gtk.Widget; 41 | private content!: Gtk.Widget; 42 | 43 | constructor(params?: Partial) { 44 | super(params); 45 | 46 | this.bind_property( 47 | "sidebar", 48 | this._sidebar_page, 49 | "child", 50 | GObject.BindingFlags.SYNC_CREATE, 51 | ); 52 | 53 | this.bind_property( 54 | "content", 55 | this._content_page, 56 | "child", 57 | GObject.BindingFlags.SYNC_CREATE, 58 | ); 59 | } 60 | 61 | listeners = new SignalListeners(); 62 | 63 | vfunc_map() { 64 | super.vfunc_map(); 65 | 66 | this.listeners.connect( 67 | get_navigator(this), 68 | "show-content", 69 | () => (this._split_view.show_content = true), 70 | ); 71 | } 72 | 73 | vfunc_unmap() { 74 | this.listeners.clear(); 75 | 76 | super.vfunc_unmap(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/layout/sidebar.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | import Gtk from "gi://Gtk?version=4.0"; 3 | import Adw from "gi://Adw"; 4 | import Gio from "gi://Gio"; 5 | 6 | import { get_current_user, get_option } from "libmuse"; 7 | 8 | import { get_navigator } from "../navigation"; 9 | import { NavbarView } from "../components/navbar/index"; 10 | 11 | GObject.type_ensure(NavbarView.$gtype); 12 | 13 | export class WindowSidebar extends Adw.Bin { 14 | static { 15 | GObject.registerClass( 16 | { 17 | GTypeName: "WindowSidebar", 18 | Template: "resource:///com/vixalien/muzika/ui/layout/sidebar.ui", 19 | InternalChildren: ["account", "login", "navbar"], 20 | }, 21 | this, 22 | ); 23 | } 24 | 25 | private _account!: Gtk.MenuButton; 26 | private _login!: Gtk.Button; 27 | private _navbar!: NavbarView; 28 | 29 | constructor() { 30 | super(); 31 | 32 | this.token_changed(); 33 | } 34 | 35 | private navbar_searched_cb() { 36 | get_navigator().emit("show-content"); 37 | } 38 | 39 | private navbar_activated_cb(_: NavbarView, uri: string) { 40 | const navigator = get_navigator(); 41 | 42 | navigator.emit("show-content"); 43 | navigator.switch_stack(uri); 44 | } 45 | 46 | async token_changed() { 47 | const has_token = get_option("auth").has_token(); 48 | 49 | this._account.visible = has_token; 50 | this._login.visible = !has_token; 51 | 52 | if (!has_token) { 53 | return; 54 | } 55 | 56 | const account = await get_current_user().catch(() => { 57 | console.error("Couldn't get logged in user"); 58 | }); 59 | 60 | const menu = Gio.Menu.new(); 61 | 62 | if (account) { 63 | menu.append( 64 | account.name, 65 | `navigator.visit("muzika:channel:${account.channel_id}")`, 66 | ); 67 | } 68 | 69 | menu.append(_("Logout"), "win.logout"); 70 | 71 | this._account.menu_model = menu; 72 | } 73 | 74 | private _search_changed_signal?: number; 75 | 76 | vfunc_realize(): void { 77 | super.vfunc_realize(); 78 | 79 | this._search_changed_signal = get_navigator(this).connect( 80 | "search-changed", 81 | (_, search: string) => { 82 | this._navbar.set_search(search); 83 | }, 84 | ); 85 | } 86 | 87 | vfunc_unrealize(): void { 88 | if (this._search_changed_signal) { 89 | get_navigator(this).disconnect(this._search_changed_signal); 90 | } 91 | 92 | super.vfunc_unrealize(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /* MIT License 2 | * 3 | * Copyright (c) 2023 Chris Davis 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to deal 7 | * in the Software without restriction, including without limitation the rights 8 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | * copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in all 13 | * copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | * SOFTWARE. 22 | * 23 | * SPDX-License-Identifier: MIT 24 | */ 25 | 26 | import { Application } from "./application"; 27 | 28 | export function main(argv: string[]): number { 29 | const app = new Application(argv); 30 | 31 | return app.run(argv); 32 | } 33 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | gnome = import('gnome') 2 | 3 | yarn_args = ['--cwd', meson.project_source_root()] 4 | yarnrc = get_option('yarnrc') 5 | 6 | if yarnrc != '' 7 | yarn_args += ['--offline', '--use-yarnrc', yarnrc] 8 | endif 9 | 10 | yarn_deps = custom_target( 11 | 'yarn-deps', 12 | command: [ yarn, yarn_args, 'install' ], 13 | output: ['node_modules'] 14 | ) 15 | 16 | typescript = custom_target( 17 | 'typescript-compile', 18 | input: ['main.ts'], 19 | build_by_default: true, 20 | build_always_stale: true, 21 | command: [ yarn, yarn_args, 'run', 'build', '--out', meson.project_build_root() / '@OUTDIR@', '--cwd', meson.project_build_root(), '@INPUT@' ], 22 | depends: yarn_deps, 23 | output: ['main.js'], 24 | ) 25 | 26 | src_res = gnome.compile_resources( 27 | application_id + '.src', 28 | '@0@.src.gresource.xml'.format(base_name), 29 | dependencies: typescript, 30 | gresource_bundle: true, 31 | install: true, 32 | install_dir: muzika_pkgdatadir, 33 | ) 34 | 35 | bin_conf = configuration_data() 36 | bin_conf.set('GJS', gjs_console) 37 | bin_conf.set('PACKAGE_VERSION', '@0@@1@'.format(meson.project_version(), version_suffix)) 38 | bin_conf.set('APPLICATION_ID', application_id) 39 | bin_conf.set('prefix', muzika_prefix) 40 | bin_conf.set('libdir', muzika_libdir) 41 | bin_conf.set('datadir', muzika_datadir) 42 | bin_conf.set('resource_path', '/com/vixalien/muzika') 43 | bin_conf.set('profile', profile) 44 | 45 | app_launcher = configure_file( 46 | input: '@0@.in'.format(base_name), 47 | output: application_id, 48 | configuration: bin_conf, 49 | install: true, 50 | install_dir: muzika_bindir 51 | ) 52 | 53 | run_target( 54 | 'devel', 55 | command: [gjs_console, '-m', app_launcher], 56 | depends: [src_res, data_res, compile_local_schemas] 57 | ) 58 | -------------------------------------------------------------------------------- /src/muse.ts: -------------------------------------------------------------------------------- 1 | // URL, URLSearchParams 2 | import "core-js/features/url"; 3 | 4 | // Headers 5 | import { Headers } from "headers-polyfill"; 6 | globalThis.Headers = Headers; 7 | 8 | // Headers 9 | 10 | // base64 11 | import "./polyfills/base64.js"; 12 | 13 | // abortcontroller 14 | import "./polyfills/abortcontroller.js"; 15 | 16 | //////////// store 17 | 18 | // types 19 | 20 | import { setup } from "libmuse"; 21 | import { MuzikaSecretStore } from "./util/secret-store.js"; 22 | import { fetch } from "./polyfills/fetch.js"; 23 | 24 | setup({ 25 | store: new MuzikaSecretStore(), 26 | fetch: fetch as unknown as typeof globalThis.fetch, 27 | }); 28 | -------------------------------------------------------------------------------- /src/pages/authentication-error.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import GLib from "gi://GLib"; 4 | import Adw from "gi://Adw"; 5 | 6 | import { get_option } from "libmuse"; 7 | 8 | import { error_to_string, ErrorPageOptions } from "./error.js"; 9 | 10 | export class AuthenticationErrorPage extends Adw.Bin { 11 | static { 12 | GObject.registerClass( 13 | { 14 | GTypeName: "AuthenticationErrorPage", 15 | Template: 16 | "resource:///com/vixalien/muzika/ui/pages/authentication-error.ui", 17 | InternalChildren: ["status", "more", "text_view", "home_button"], 18 | }, 19 | this, 20 | ); 21 | } 22 | 23 | _status!: Adw.StatusPage; 24 | _more!: Gtk.Box; 25 | _text_view!: Gtk.TextView; 26 | _home_button!: Gtk.Button; 27 | 28 | buffer: Gtk.TextBuffer; 29 | 30 | constructor(options: ErrorPageOptions = {}) { 31 | super(); 32 | 33 | this._text_view.buffer = this.buffer = new Gtk.TextBuffer(); 34 | 35 | this._home_button.connect("clicked", () => { 36 | get_option("auth").token = null; 37 | 38 | this.activate_action( 39 | "navigator.visit", 40 | GLib.Variant.new_string("muzika:home"), 41 | ); 42 | 43 | if (options.error) { 44 | this.set_error(options.error); 45 | } 46 | }); 47 | } 48 | 49 | set_more(show: boolean, label?: string) { 50 | this._more.visible = show; 51 | if (label) { 52 | this.buffer.text = ""; 53 | this.buffer.insert_markup( 54 | this.buffer.get_start_iter(), 55 | `${label.replace(/\n$/, "")}`, 56 | -1, 57 | ); 58 | } 59 | } 60 | 61 | set_error(error: unknown) { 62 | if (error instanceof Error) { 63 | this.set_more(!!error, error_to_string(error)); 64 | } else { 65 | this.set_more(false); 66 | } 67 | 68 | if (get_option("auth").has_token()) { 69 | this._status.set_description( 70 | _( 71 | "Your authentication details have expired. Please log in again or go to home.", 72 | ), 73 | ); 74 | } else { 75 | this._status.set_description( 76 | _("The page you were trying to access requires you to log in."), 77 | ); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/pages/error.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Adw from "gi://Adw"; 4 | import GLib from "gi://GLib"; 5 | import Gio from "gi://Gio"; 6 | 7 | import { escape_label, indent_stack } from "src/util/text"; 8 | 9 | export function error_to_string(error: Error) { 10 | let message = `${escape_label(error.name)}: ${escape_label(error.message)}\n`; 11 | 12 | if (error.cause) { 13 | message += `${error.cause}\n`; 14 | } 15 | 16 | message += `${indent_stack(error.stack ?? "")}`; 17 | 18 | return message; 19 | } 20 | 21 | export interface ErrorPageOptions { 22 | error?: unknown; 23 | } 24 | 25 | export class ErrorPage extends Adw.Bin { 26 | static { 27 | GObject.registerClass( 28 | { 29 | GTypeName: "ErrorPage", 30 | Template: "resource:///com/vixalien/muzika/ui/pages/error.ui", 31 | InternalChildren: ["status", "more", "text_view"], 32 | }, 33 | this, 34 | ); 35 | } 36 | 37 | _status!: Adw.StatusPage; 38 | _more!: Gtk.Box; 39 | _text_view!: Gtk.TextView; 40 | 41 | buffer: Gtk.TextBuffer; 42 | 43 | constructor(options: ErrorPageOptions = {}) { 44 | super(); 45 | 46 | this._text_view.buffer = this.buffer = new Gtk.TextBuffer(); 47 | 48 | if (options.error) { 49 | this.set_error(options.error); 50 | } 51 | } 52 | 53 | set_message(message: string) { 54 | this._status.set_description(message); 55 | } 56 | 57 | set_more(show: boolean, label?: string) { 58 | this._more.visible = show; 59 | if (label) { 60 | this.buffer.text = ""; 61 | this.buffer.insert_markup( 62 | this.buffer.get_start_iter(), 63 | `${label.replace(/\n$/, "")}`, 64 | -1, 65 | ); 66 | } 67 | } 68 | 69 | set_error(error: unknown) { 70 | if (error instanceof GLib.Error) { 71 | if ( 72 | error instanceof Gio.ResolverError && 73 | !Gio.NetworkMonitor.get_default().network_available 74 | ) { 75 | this._status.title = _("Connect to the internet"); 76 | this.set_message( 77 | _("You're offline. Check your connection and try again."), 78 | ); 79 | this.set_more(!!error, error.toString()); 80 | } else { 81 | this.set_message(error.message); 82 | this.set_more(!!error, error.toString()); 83 | } 84 | } else if (error instanceof Error) { 85 | this.set_message(`${error.name}: ${error.message}`); 86 | this.set_more(!!error, error_to_string(error)); 87 | } else { 88 | this.set_message(error ? _(`Error: ${error}`) : _("Unknown error")); 89 | this.set_more(false); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/pages/library/albums.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | 3 | import { get_library_albums } from "libmuse"; 4 | 5 | import { AbstractLibraryPage } from "./base"; 6 | 7 | export class LibraryAlbumsPage extends AbstractLibraryPage { 8 | static { 9 | GObject.registerClass( 10 | { 11 | GTypeName: "LibraryAlbumsPage", 12 | }, 13 | this, 14 | ); 15 | } 16 | 17 | constructor() { 18 | super({ 19 | uri: "library:albums", 20 | }); 21 | } 22 | 23 | static load = this.get_loader(get_library_albums); 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/library/artists.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | 3 | import { get_library_artists } from "libmuse"; 4 | 5 | import { AbstractLibraryPage } from "./base"; 6 | 7 | export class LibraryArtistsPage extends AbstractLibraryPage { 8 | static { 9 | GObject.registerClass( 10 | { 11 | GTypeName: "LibraryArtistsPage", 12 | }, 13 | this, 14 | ); 15 | } 16 | 17 | constructor() { 18 | super({ 19 | uri: "library:artists", 20 | }); 21 | } 22 | 23 | static load = this.get_loader(get_library_artists); 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/library/index.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | 3 | import { get_library } from "libmuse"; 4 | import type { LibraryOrder } from "libmuse"; 5 | 6 | import { AbstractLibraryPage, library_orders, LibraryLoader } from "./base"; 7 | import { RequiredMixedItem } from "src/components/carousel/index.js"; 8 | 9 | export class LibraryPage extends AbstractLibraryPage { 10 | static { 11 | GObject.registerClass( 12 | { 13 | GTypeName: "LibraryPage", 14 | }, 15 | this, 16 | ); 17 | } 18 | 19 | constructor() { 20 | super({ 21 | orders: library_orders, 22 | uri: "library", 23 | }); 24 | } 25 | 26 | static loader: LibraryLoader = async (options) => { 27 | const library = await get_library(options); 28 | 29 | return { 30 | continuation: library.continuation, 31 | items: library.results.filter(Boolean) as RequiredMixedItem[], 32 | }; 33 | }; 34 | 35 | static load = this.get_loader(this.loader); 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/library/playlists.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | 3 | import { get_library_playlists } from "libmuse"; 4 | 5 | import { AbstractLibraryPage } from "./base"; 6 | 7 | export class LibraryPlaylistsPage extends AbstractLibraryPage { 8 | static { 9 | GObject.registerClass( 10 | { 11 | GTypeName: "LibraryPlaylistsPage", 12 | }, 13 | this, 14 | ); 15 | } 16 | 17 | constructor() { 18 | super({ 19 | uri: "library:playlists", 20 | }); 21 | } 22 | 23 | static load = this.get_loader(get_library_playlists); 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/library/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | 3 | import { get_library_subscriptions } from "libmuse"; 4 | 5 | import { AbstractLibraryPage } from "./base"; 6 | 7 | export class LibrarySubscriptionsPage extends AbstractLibraryPage { 8 | static { 9 | GObject.registerClass( 10 | { 11 | GTypeName: "LibrarySubscriptionsPage", 12 | }, 13 | this, 14 | ); 15 | } 16 | 17 | constructor() { 18 | super({ 19 | uri: "library:subscriptions", 20 | }); 21 | } 22 | 23 | static load = this.get_loader(get_library_subscriptions); 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/mood-playlists.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Adw from "gi://Adw"; 4 | 5 | import { get_mood_playlists } from "libmuse"; 6 | import type { MoodPlaylists } from "libmuse"; 7 | 8 | import { Carousel } from "../components/carousel/index.js"; 9 | import { Loading } from "../components/loading.js"; 10 | import { MuzikaPageWidget, PageLoadContext } from "src/navigation.js"; 11 | import { 12 | set_scrolled_window_initial_vscroll, 13 | VScrollState, 14 | } from "src/util/scrolled.js"; 15 | 16 | GObject.type_ensure(Loading.$gtype); 17 | 18 | export interface MoodPlaylistsPageState extends VScrollState { 19 | contents: MoodPlaylists; 20 | } 21 | 22 | export class MoodPlaylistsPage 23 | extends Adw.Bin 24 | implements MuzikaPageWidget 25 | { 26 | static { 27 | GObject.registerClass( 28 | { 29 | GTypeName: "MoodPlaylistsPage", 30 | Template: "resource:///com/vixalien/muzika/ui/pages/mood-playlists.ui", 31 | InternalChildren: ["scrolled", "box"], 32 | }, 33 | this, 34 | ); 35 | } 36 | 37 | private _scrolled!: Gtk.ScrolledWindow; 38 | private _box!: Gtk.Box; 39 | 40 | contents?: MoodPlaylists; 41 | 42 | static async load(ctx: PageLoadContext) { 43 | const data = await get_mood_playlists(ctx.match.params.params, { 44 | signal: ctx.signal, 45 | }); 46 | 47 | ctx.set_title(data.title); 48 | 49 | return data; 50 | } 51 | 52 | present(mood_playlists: MoodPlaylists): void { 53 | this.contents = mood_playlists; 54 | 55 | mood_playlists.categories.forEach((category) => { 56 | this.add_carousel(category); 57 | }); 58 | } 59 | 60 | get_state() { 61 | return { 62 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 63 | contents: this.contents!, 64 | vscroll: this._scrolled.get_vadjustment().get_value(), 65 | }; 66 | } 67 | 68 | restore_state(state: MoodPlaylistsPageState) { 69 | set_scrolled_window_initial_vscroll(this._scrolled, state.vscroll); 70 | this.present(state.contents); 71 | } 72 | 73 | clear() { 74 | let child = this._box.get_first_child(); 75 | 76 | while (child) { 77 | const current = child; 78 | child = child.get_next_sibling(); 79 | this._box.remove(current); 80 | } 81 | } 82 | 83 | private add_carousel(data: MoodPlaylists["categories"][0]) { 84 | if (!data || data.playlists.length === 0) return; 85 | 86 | const carousel = new Carousel(); 87 | 88 | carousel.show_content({ 89 | title: data.title, 90 | contents: data.playlists, 91 | }); 92 | 93 | this._box.append(carousel); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/pages/moods.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Adw from "gi://Adw"; 4 | 5 | import { get_mood_categories } from "libmuse"; 6 | import type { MoodCategories } from "libmuse"; 7 | 8 | import { Carousel } from "../components/carousel/index.js"; 9 | import { Loading } from "../components/loading.js"; 10 | import { MuzikaPageWidget, PageLoadContext } from "src/navigation.js"; 11 | import { 12 | set_scrolled_window_initial_vscroll, 13 | VScrollState, 14 | } from "src/util/scrolled.js"; 15 | 16 | GObject.type_ensure(Loading.$gtype); 17 | 18 | export interface MoodsPageState extends VScrollState { 19 | contents: MoodCategories; 20 | } 21 | 22 | export class MoodsPage 23 | extends Adw.Bin 24 | implements MuzikaPageWidget 25 | { 26 | static { 27 | GObject.registerClass( 28 | { 29 | GTypeName: "MoodsPage", 30 | Template: "resource:///com/vixalien/muzika/ui/pages/moods.ui", 31 | InternalChildren: ["scrolled", "box"], 32 | }, 33 | this, 34 | ); 35 | } 36 | 37 | private _scrolled!: Gtk.ScrolledWindow; 38 | private _box!: Gtk.Box; 39 | 40 | contents?: MoodCategories; 41 | 42 | static load(ctx: PageLoadContext) { 43 | return get_mood_categories({ 44 | signal: ctx.signal, 45 | }); 46 | } 47 | 48 | present(moods: MoodCategories): void { 49 | this.contents = moods; 50 | 51 | moods.categories.forEach((category) => { 52 | this.add_carousel(category); 53 | }); 54 | } 55 | 56 | get_state() { 57 | return { 58 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 59 | contents: this.contents!, 60 | vscroll: this._scrolled.get_vadjustment().get_value(), 61 | }; 62 | } 63 | 64 | restore_state(state: MoodsPageState) { 65 | set_scrolled_window_initial_vscroll(this._scrolled, state.vscroll); 66 | this.present(state.contents); 67 | } 68 | 69 | clear() { 70 | let child = this._box.get_first_child(); 71 | 72 | while (child) { 73 | const current = child; 74 | child = child.get_next_sibling(); 75 | this._box.remove(current); 76 | } 77 | 78 | return; 79 | } 80 | 81 | private add_carousel(data: MoodCategories["categories"][0]) { 82 | if (!data || data.items.length === 0) return; 83 | 84 | const carousel = new Carousel(); 85 | 86 | carousel.show_content({ 87 | title: data.title, 88 | display: "mood", 89 | // @ts-expect-error idk what's going on here 90 | contents: data.items, 91 | }); 92 | 93 | this._box.append(carousel); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/pages/new-releases.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Adw from "gi://Adw"; 4 | 5 | import { get_new_releases } from "libmuse"; 6 | import type { NewReleases } from "libmuse"; 7 | 8 | import { Carousel } from "../components/carousel/index.js"; 9 | import { Loading } from "../components/loading.js"; 10 | import { MuzikaPageWidget, PageLoadContext } from "src/navigation.js"; 11 | import { 12 | set_scrolled_window_initial_vscroll, 13 | VScrollState, 14 | } from "src/util/scrolled.js"; 15 | 16 | GObject.type_ensure(Loading.$gtype); 17 | 18 | export interface NewReleasesPageState extends VScrollState { 19 | contents: NewReleases; 20 | } 21 | 22 | export class NewReleasesPage 23 | extends Adw.Bin 24 | implements MuzikaPageWidget 25 | { 26 | static { 27 | GObject.registerClass( 28 | { 29 | GTypeName: "NewReleasesPage", 30 | Template: "resource:///com/vixalien/muzika/ui/pages/new-releases.ui", 31 | InternalChildren: ["scrolled", "box"], 32 | }, 33 | this, 34 | ); 35 | } 36 | 37 | private _scrolled!: Gtk.ScrolledWindow; 38 | private _box!: Gtk.Box; 39 | 40 | contents?: NewReleases; 41 | 42 | static async load(ctx: PageLoadContext) { 43 | const data = await get_new_releases({ 44 | signal: ctx.signal, 45 | }); 46 | 47 | ctx.set_title(data.title); 48 | 49 | return data; 50 | } 51 | 52 | present(new_releases: NewReleases): void { 53 | this.contents = new_releases; 54 | 55 | new_releases.categories.forEach((category) => { 56 | this.add_carousel(category); 57 | }); 58 | } 59 | 60 | get_state() { 61 | return { 62 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 63 | contents: this.contents!, 64 | vscroll: this._scrolled.get_vadjustment().get_value(), 65 | }; 66 | } 67 | 68 | restore_state(state: NewReleasesPageState) { 69 | set_scrolled_window_initial_vscroll(this._scrolled, state.vscroll); 70 | this.present(state.contents); 71 | } 72 | 73 | clear() { 74 | let child = this._box.get_first_child(); 75 | 76 | while (child) { 77 | const current = child; 78 | child = child.get_next_sibling(); 79 | this._box.remove(current); 80 | } 81 | 82 | return; 83 | } 84 | 85 | private add_carousel(content: NewReleases["categories"][0]) { 86 | if (!content || content.contents.length === 0) return; 87 | 88 | const carousel = new Carousel(); 89 | 90 | carousel.show_content(content); 91 | 92 | this._box.append(carousel); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/pages/preferences.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | import Gio from "gi://Gio"; 4 | import Adw from "gi://Adw"; 5 | 6 | import { ObjectContainer } from "src/util/objectcontainer"; 7 | import { AudioQualities, VideoQualities } from "src/player"; 8 | import { Settings } from "src/util/settings.js"; 9 | 10 | export class MuzikaPreferencesDialog extends Adw.PreferencesDialog { 11 | static { 12 | GObject.registerClass( 13 | { 14 | GTypeName: "MuzikaPreferencesDialog", 15 | Template: "resource:///com/vixalien/muzika/ui/pages/preferences.ui", 16 | InternalChildren: [ 17 | "audio_quality", 18 | "video_quality", 19 | "background_play", 20 | "inhibit_suspend", 21 | ], 22 | }, 23 | this, 24 | ); 25 | } 26 | 27 | private _video_quality!: Adw.ComboRow; 28 | private _audio_quality!: Adw.ComboRow; 29 | private _background_play!: Adw.SwitchRow; 30 | private _inhibit_suspend!: Adw.SwitchRow; 31 | 32 | constructor() { 33 | super(); 34 | 35 | this.prepare_quality(this._audio_quality, AudioQualities, "audio-quality"); 36 | this.prepare_quality(this._video_quality, VideoQualities, "video-quality"); 37 | 38 | Settings.bind( 39 | "background-play", 40 | this._background_play, 41 | "active", 42 | Gio.SettingsBindFlags.DEFAULT, 43 | ); 44 | 45 | Settings.bind( 46 | "inhibit-suspend", 47 | this._inhibit_suspend, 48 | "active", 49 | Gio.SettingsBindFlags.DEFAULT, 50 | ); 51 | } 52 | 53 | private prepare_quality( 54 | widget: typeof this._audio_quality | typeof this._video_quality, 55 | qualities: typeof AudioQualities | typeof VideoQualities, 56 | gsettings_key: string, 57 | ) { 58 | const model = new Gio.ListStore(); 59 | model.splice( 60 | 0, 61 | 0, 62 | qualities.map((e) => new ObjectContainer(e)), 63 | ); 64 | 65 | widget.expression = Gtk.ClosureExpression.new( 66 | GObject.TYPE_STRING, 67 | (container: ObjectContainer<(typeof qualities)[number]>) => { 68 | return container.object.name; 69 | }, 70 | [], 71 | ); 72 | 73 | widget.model = model; 74 | widget.selected = Settings.get_enum(gsettings_key); 75 | 76 | widget.connect("notify::selected", () => { 77 | const value = widget.selected; 78 | 79 | if (value != Settings.get_enum(gsettings_key)) { 80 | Settings.set_enum(gsettings_key, value); 81 | } 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/polyfills/abortcontroller.ts: -------------------------------------------------------------------------------- 1 | import GLib from "gi://GLib"; 2 | 3 | //////////// polyfills 4 | 5 | import "./customevent.js"; 6 | 7 | export class AbortSignal extends EventTarget implements globalThis.AbortSignal { 8 | private _aborted = false; 9 | 10 | dispatchEvent(event: CustomEvent): boolean { 11 | if (event.type === "abort") { 12 | this._aborted = true; 13 | this._reason = event.detail; 14 | } 15 | 16 | return super.dispatchEvent(event); 17 | } 18 | 19 | get aborted() { 20 | return this._aborted; 21 | } 22 | 23 | private _reason: unknown = null; 24 | 25 | get reason() { 26 | return this._reason; 27 | } 28 | 29 | private _onabort: ((this: globalThis.AbortSignal, ev: Event) => void) | null = 30 | null; 31 | 32 | get onabort() { 33 | return this._onabort; 34 | } 35 | 36 | set onabort(value) { 37 | this._onabort = value; 38 | if (value) { 39 | this.addEventListener("abort", value); 40 | } else { 41 | this.removeEventListener("abort", value); 42 | } 43 | } 44 | 45 | throwIfAborted(): void { 46 | if (this.aborted) { 47 | throw this.reason; 48 | } 49 | } 50 | 51 | static timeout(ms: number) { 52 | const signal = new this(); 53 | 54 | GLib.timeout_add(GLib.PRIORITY_DEFAULT_IDLE, ms, () => { 55 | signal.dispatchEvent( 56 | new CustomEvent("abort", { 57 | detail: new DOMException( 58 | "The operation was aborted. ", 59 | "TimeoutError", 60 | ), 61 | }), 62 | ); 63 | 64 | return GLib.SOURCE_REMOVE; 65 | }); 66 | 67 | return signal; 68 | } 69 | 70 | static abort() { 71 | const signal = new this(); 72 | 73 | signal.dispatchEvent( 74 | new CustomEvent("abort", { 75 | detail: new DOMException("The operation was aborted. ", "AbortError"), 76 | }), 77 | ); 78 | 79 | return signal; 80 | } 81 | } 82 | 83 | export class AbortController implements globalThis.AbortController { 84 | readonly signal = new AbortSignal(); 85 | 86 | abort() { 87 | this.signal.dispatchEvent( 88 | new CustomEvent("abort", { 89 | detail: new DOMException("The operation was aborted", "AbortError"), 90 | }), 91 | ); 92 | } 93 | } 94 | 95 | if (!globalThis.AbortSignal) { 96 | // @ts-expect-error overriding a global 97 | globalThis.AbortSignal = AbortSignal; 98 | } 99 | 100 | if (!globalThis.AbortController) { 101 | globalThis.AbortController = AbortController; 102 | } 103 | -------------------------------------------------------------------------------- /src/polyfills/base64.ts: -------------------------------------------------------------------------------- 1 | import GLib from "gi://GLib"; 2 | 3 | const encoder = new TextEncoder(); 4 | const decoder = new TextDecoder(); 5 | 6 | export function atob(string: string) { 7 | return decoder.decode(GLib.base64_decode(string)); 8 | } 9 | 10 | export function btoa(string: string) { 11 | return GLib.base64_encode(encoder.encode(string)); 12 | } 13 | 14 | globalThis.atob = atob; 15 | globalThis.btoa = btoa; 16 | -------------------------------------------------------------------------------- /src/polyfills/customevent.ts: -------------------------------------------------------------------------------- 1 | //////////// polyfills 2 | 3 | // EventTarget 4 | import "event-target-polyfill"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export class CustomEvent 8 | extends Event 9 | implements globalThis.CustomEvent 10 | { 11 | readonly detail: T; 12 | 13 | constructor(type: string, eventInitDict?: CustomEventInit) { 14 | super(type); 15 | this.detail = eventInitDict?.detail ?? (null as T); 16 | } 17 | 18 | initCustomEvent(): void { 19 | throw new Error("Method not implemented."); 20 | } 21 | } 22 | 23 | if (!globalThis.CustomEvent) { 24 | globalThis.CustomEvent = CustomEvent; 25 | } 26 | -------------------------------------------------------------------------------- /src/util/controllers/background.ts: -------------------------------------------------------------------------------- 1 | import Gio from "gi://Gio"; 2 | import Gtk from "gi://Gtk?version=4.0"; 3 | // @ts-expect-error unknown types 4 | import Xdp from "gi://Xdp"; 5 | // @ts-expect-error unknown types 6 | import XdpGtk4 from "gi://XdpGtk4"; 7 | 8 | Gio._promisify( 9 | Xdp.Portal.prototype, 10 | "request_background", 11 | "request_background_finish", 12 | ); 13 | 14 | Gio._promisify( 15 | Xdp.Portal.prototype, 16 | "set_background_status", 17 | "set_background_status_finish", 18 | ); 19 | 20 | export class MuzikaBackgroundController { 21 | private portal = new Xdp.Portal(); 22 | 23 | request(window: Gtk.Window) { 24 | const parent = window ? XdpGtk4.parent_new_gtk(window) : null; 25 | 26 | return this.portal 27 | .request_background( 28 | parent, 29 | _("Muzika needs to run in the background to play music"), 30 | [pkg.name], 31 | Xdp.BackgroundFlags.NONE, 32 | null, 33 | ) 34 | .catch(() => { 35 | console.error("Permission to run in the background not granted"); 36 | }); 37 | } 38 | 39 | set_status() { 40 | this.portal.set_background_status(_("Playing in the background"), null); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/util/controllers/hold.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | import { Settings } from "src/util/settings.js"; 5 | 6 | /** 7 | * Allows Muzika to request/not-request playing in the background 8 | */ 9 | export class MuzikaHoldController extends GObject.Object { 10 | static { 11 | GObject.registerClass( 12 | { 13 | GTypeName: "MuzikaHoldController", 14 | Properties: { 15 | active: GObject.param_spec_boolean( 16 | "active", 17 | "Active", 18 | "Whether the app is playing in the background", 19 | false, 20 | GObject.ParamFlags.READWRITE, 21 | ), 22 | }, 23 | }, 24 | this, 25 | ); 26 | } 27 | 28 | listener: number; 29 | 30 | constructor() { 31 | super(); 32 | 33 | /** 34 | * track changes in the `background-play` key, and enable/disable background 35 | * playback respectively based on if there's an ongoing task 36 | */ 37 | this.listener = Settings.connect("changed::background-play", () => { 38 | this.set_holding(this.active); 39 | }); 40 | } 41 | 42 | clear() { 43 | Settings.disconnect(this.listener); 44 | } 45 | 46 | private _active = false; 47 | 48 | get active() { 49 | return this._active; 50 | } 51 | 52 | set active(value) { 53 | if (this.active === value) return; 54 | 55 | this.set_holding(value); 56 | 57 | this._active = value; 58 | this.notify("active"); 59 | } 60 | 61 | private set_holding(value: boolean) { 62 | if (value && Settings.get_boolean("background-play")) { 63 | Gtk.Application.get_default()?.hold(); 64 | } else { 65 | Gtk.Application.get_default()?.release(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/util/controllers/inhibit.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GObject from "gi://GObject"; 3 | 4 | /** 5 | * Allows Muzika to request the session to not suspend 6 | */ 7 | export class MuzikaInhibitController extends GObject.Object { 8 | static { 9 | GObject.registerClass( 10 | { 11 | GTypeName: "MuzikaInhibitController", 12 | Properties: { 13 | active: GObject.param_spec_boolean( 14 | "active", 15 | "Active", 16 | "Whether the app has inhibitted suspend", 17 | false, 18 | GObject.ParamFlags.READABLE, 19 | ), 20 | }, 21 | }, 22 | this, 23 | ); 24 | } 25 | 26 | private cookie: number | null = null; 27 | 28 | get active() { 29 | return this.cookie !== null; 30 | } 31 | 32 | set active(value) { 33 | if (this.active === value) return; 34 | 35 | if (value) { 36 | this.inhibit(); 37 | } else { 38 | this.uninhibit(); 39 | } 40 | } 41 | 42 | private last_was_video = false; 43 | 44 | inhibit(video = false) { 45 | if (this.active === true) { 46 | // only re-inhibit if we are now inhibiting video 47 | if (this.last_was_video !== video) this.uninhibit(); 48 | else return; 49 | } 50 | 51 | const app = Gtk.Application.get_default() as Gtk.Application; 52 | if (!app) return; 53 | 54 | let reason: string, flags: Gtk.ApplicationInhibitFlags; 55 | 56 | if (video) { 57 | reason = _("Muzika is playing video"); 58 | flags = 59 | Gtk.ApplicationInhibitFlags.SUSPEND | Gtk.ApplicationInhibitFlags.IDLE; 60 | } else { 61 | reason = _("Muzika is playing music"); 62 | flags = Gtk.ApplicationInhibitFlags.SUSPEND; 63 | } 64 | 65 | this.cookie = app.inhibit(app.get_active_window(), flags, reason); 66 | 67 | this.last_was_video = video; 68 | this.notify("active"); 69 | } 70 | 71 | uninhibit() { 72 | const app = Gtk.Application.get_default() as Gtk.Application; 73 | 74 | if (!this.cookie || !app) return; 75 | 76 | app.uninhibit(this.cookie); 77 | this.cookie = null; 78 | 79 | this.notify("active"); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/util/hash.ts: -------------------------------------------------------------------------------- 1 | import GLib from "gi://GLib"; 2 | 3 | const encoder = new TextEncoder(); 4 | 5 | export function hash(string: string) { 6 | // use the subtle crypto API to generate a hash 7 | // return the hash as a hex string 8 | const hash = GLib.Checksum.new(GLib.ChecksumType.MD5); 9 | 10 | hash.update(string); 11 | 12 | const digest = encoder.encode(hash.get_string()); 13 | 14 | return Array.from(digest) 15 | .map((b) => b.toString(16).padStart(2, "0")) 16 | .join(""); 17 | } 18 | 19 | export function get_cache_name(href: string) { 20 | const url = new URL(href); 21 | 22 | return hash(url.href); 23 | } 24 | -------------------------------------------------------------------------------- /src/util/label.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GLib from "gi://GLib"; 3 | import GObject from "gi://GObject"; 4 | 5 | import { SignalListeners } from "./signal-listener"; 6 | 7 | export function setup_link_label( 8 | label: Gtk.Label, 9 | listeners?: SignalListeners, 10 | ) { 11 | function connect( 12 | widget: Obj, 13 | signal: Signal, 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | fn: (...args: any[]) => unknown, 16 | ) { 17 | if (listeners) { 18 | return listeners.connect(widget, signal, fn); 19 | } 20 | 21 | return widget.connect(signal, fn); 22 | } 23 | 24 | connect(label, "activate-link", (_: Gtk.Label, uri: string) => { 25 | if (uri && uri.startsWith("muzika:")) { 26 | label.activate_action("navigator.visit", GLib.Variant.new_string(uri)); 27 | 28 | return true; 29 | } 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/util/language.ts: -------------------------------------------------------------------------------- 1 | import Pango from "gi://Pango"; 2 | 3 | import { set_option } from "libmuse"; 4 | import locales from "libmuse/locales/locales"; 5 | 6 | function get_muse_lang(language: string) { 7 | for (const lang of locales.languages) { 8 | if (lang.value.toLowerCase() === language.toLowerCase()) return lang; 9 | } 10 | 11 | // do a second pass to get match "en" instead of "en-US" and similar 12 | for (const lang of locales.languages) { 13 | if (lang.value.toLowerCase() === language.toLowerCase().split("-")[0]) { 14 | return lang; 15 | } 16 | } 17 | 18 | return null; 19 | } 20 | 21 | export function get_default_muse_lang() { 22 | const default_lang = get_muse_lang(Pango.Language.get_default().to_string()); 23 | 24 | if (default_lang) return default_lang; 25 | 26 | for (const language of Pango.Language.get_preferred() || []) { 27 | const muse_lang = get_muse_lang(language.to_string()); 28 | 29 | if (muse_lang) return muse_lang; 30 | } 31 | 32 | return null; 33 | } 34 | 35 | export function set_muse_lang() { 36 | const lang = get_default_muse_lang(); 37 | 38 | if (lang) { 39 | set_option("language", lang.value); 40 | } 41 | } 42 | 43 | export function get_language_string(value: string) { 44 | const lang = locales.languages.find((lang) => { 45 | return lang.value === value; 46 | }); 47 | 48 | if (lang) { 49 | return `${lang.name} - ${lang.value}`; 50 | } 51 | 52 | return _("Invalid language selected"); 53 | } 54 | -------------------------------------------------------------------------------- /src/util/list.ts: -------------------------------------------------------------------------------- 1 | import Gio from "gi://Gio"; 2 | import GObject from "gi://GObject"; 3 | import Gtk from "gi://Gtk?version=4.0"; 4 | 5 | /** 6 | * A helper to turn a `Gio.ListModel` into an array 7 | */ 8 | export function list_model_to_array( 9 | list: Gio.ListModel, 10 | ) { 11 | const items: T[] = []; 12 | 13 | for (let i = 0; i < list.get_n_items(); i++) { 14 | const item = list.get_item(i); 15 | if (!item) continue; 16 | items.push(item); 17 | } 18 | 19 | return items; 20 | } 21 | 22 | export function get_selected( 23 | model: Gtk.SelectionModel, 24 | ) { 25 | const items = model.get_selection().get_size(); 26 | 27 | if (items <= 0) { 28 | return []; 29 | } 30 | 31 | const selected: number[] = []; 32 | 33 | const [, bitset] = Gtk.BitsetIter.init_first(model.get_selection()); 34 | 35 | while (bitset.is_valid()) { 36 | selected.push(bitset.get_value()); 37 | 38 | bitset.next(); 39 | } 40 | 41 | return selected; 42 | } 43 | -------------------------------------------------------------------------------- /src/util/objectcontainer.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | 3 | export class ObjectContainer extends GObject.Object { 4 | static { 5 | if (!GObject.type_from_name("ObjectContainer")) { 6 | GObject.registerClass( 7 | { 8 | GTypeName: "ObjectContainer", 9 | Properties: { 10 | object: GObject.ParamSpec.object( 11 | "object", 12 | "Object", 13 | "The contained object", 14 | GObject.ParamFlags.READWRITE, 15 | GObject.Object.$gtype, 16 | ), 17 | }, 18 | }, 19 | this, 20 | ); 21 | } 22 | } 23 | 24 | object: T; 25 | 26 | constructor(object: T) { 27 | super(); 28 | 29 | this.object = object; 30 | } 31 | } 32 | 33 | export class OptionalObjectContainer extends GObject.Object { 34 | static { 35 | GObject.registerClass( 36 | { 37 | GTypeName: "OptionalObjectContainer", 38 | Properties: { 39 | object: GObject.ParamSpec.object( 40 | "object", 41 | "Object", 42 | "The contained object", 43 | GObject.ParamFlags.READWRITE, 44 | GObject.Object.$gtype, 45 | ), 46 | }, 47 | }, 48 | this, 49 | ); 50 | } 51 | 52 | object?: T; 53 | 54 | constructor(item?: T) { 55 | super(); 56 | 57 | this.object = item; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/util/orientation.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | 3 | export type OrientedPair = Record; 4 | 5 | export function orientedPair( 6 | initial_value: T, 7 | initial_value2?: T, 8 | ): OrientedPair { 9 | return { 10 | "0": initial_value, 11 | "1": initial_value2 || initial_value, 12 | }; 13 | } 14 | 15 | export function get_opposite_orientation(orientation: Gtk.Orientation) { 16 | return orientation === Gtk.Orientation.HORIZONTAL 17 | ? Gtk.Orientation.VERTICAL 18 | : Gtk.Orientation.HORIZONTAL; 19 | } 20 | -------------------------------------------------------------------------------- /src/util/scrolled.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import GLib from "gi://GLib"; 3 | 4 | export function set_scrolled_window_initial_vscroll( 5 | scrolled_window: Gtk.ScrolledWindow, 6 | vscroll: number, 7 | ) { 8 | if (vscroll === 0) { 9 | return; 10 | } 11 | 12 | let signal_id: number | null = scrolled_window.vadjustment.connect( 13 | "notify::upper", 14 | () => { 15 | GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { 16 | scrolled_window.vadjustment.value = vscroll; 17 | 18 | if (signal_id !== null) { 19 | scrolled_window.vadjustment.disconnect(signal_id); 20 | signal_id = null; 21 | } 22 | 23 | return GLib.SOURCE_REMOVE; 24 | }); 25 | }, 26 | ); 27 | } 28 | 29 | export interface VScrollState { 30 | vscroll: number; 31 | } 32 | -------------------------------------------------------------------------------- /src/util/secret-store.ts: -------------------------------------------------------------------------------- 1 | import Secret from "gi://Secret"; 2 | 3 | import { Store } from "libmuse"; 4 | 5 | export interface Options { 6 | visitor_id: string; 7 | } 8 | 9 | export class MuzikaSecretStore extends Store { 10 | private map = new Map(); 11 | 12 | private attributes = { 13 | version: Secret.SchemaAttributeType.STRING, 14 | }; 15 | 16 | private schema = Secret.Schema.new( 17 | pkg.name, 18 | Secret.SchemaFlags.NONE, 19 | this.attributes, 20 | ); 21 | 22 | private key = "muse-data"; 23 | 24 | constructor() { 25 | super(); 26 | 27 | const password = Secret.password_lookup_sync( 28 | this.schema, 29 | { version: this.version }, 30 | null, 31 | ); 32 | 33 | try { 34 | if (password) { 35 | const json = JSON.parse(password); 36 | 37 | if (json.version !== this.version) { 38 | throw ""; 39 | } else { 40 | this.map = new Map(Object.entries(json)); 41 | } 42 | } 43 | } catch (error) { 44 | console.error("Failed to load secret store, resetting", error); 45 | 46 | this.map = new Map(); 47 | this.set("version", this.version); 48 | } 49 | 50 | console.info("storing token to secret store"); 51 | } 52 | 53 | get(key: string): T | null { 54 | return (this.map.get(key) as T) ?? null; 55 | } 56 | 57 | set(key: string, value: unknown): void { 58 | this.map.set(key, value); 59 | 60 | this.save(); 61 | } 62 | 63 | delete(key: string): void { 64 | this.map.delete(key); 65 | 66 | this.save(); 67 | } 68 | 69 | clear() { 70 | this.map.clear(); 71 | 72 | this.save(); 73 | 74 | Secret.password_clear_sync(this.schema, { version: this.version }, null); 75 | } 76 | 77 | private save() { 78 | const json = JSON.stringify(Object.fromEntries(this.map), null, 2); 79 | 80 | Secret.password_store_sync( 81 | this.schema, 82 | { version: this.version }, 83 | Secret.COLLECTION_DEFAULT, 84 | this.key, 85 | json, 86 | null, 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/util/settings.ts: -------------------------------------------------------------------------------- 1 | import Gio from "gi://Gio"; 2 | 3 | export const Settings = new Gio.Settings({ 4 | schema: "com.vixalien.muzika.Devel", 5 | }); 6 | 7 | export const PlayerStateSettings = new Gio.Settings({ 8 | schema: `${pkg.name}.PlayerState`, 9 | }); 10 | -------------------------------------------------------------------------------- /src/util/signal-listener.ts: -------------------------------------------------------------------------------- 1 | import GObject from "gi://GObject"; 2 | 3 | export class SignalListeners { 4 | listeners = new Map(); 5 | bindings: GObject.Binding[] = []; 6 | 7 | add(widget: GObject.Object, listener: number | number[]) { 8 | const listeners = this.listeners.get(widget) ?? []; 9 | listeners.push(...[listener].flat()); 10 | this.listeners.set(widget, listeners); 11 | } 12 | 13 | add_bindings(...bindings: GObject.Binding[]) { 14 | this.bindings.push(...bindings); 15 | } 16 | 17 | add_binding(binding: GObject.Binding) { 18 | this.add_bindings(binding); 19 | } 20 | 21 | clear() { 22 | this.listeners.forEach((listeners, widget) => { 23 | listeners.forEach((listener) => { 24 | widget.disconnect(listener); 25 | }); 26 | }); 27 | 28 | this.bindings.forEach((binding) => { 29 | binding.unbind(); 30 | }); 31 | 32 | this.listeners.clear(); 33 | this.bindings.length = 0; 34 | } 35 | 36 | connect( 37 | widget: Obj, 38 | signal: Signal, 39 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 40 | fn: (...args: any[]) => unknown, 41 | ) { 42 | const listener = widget.connect(signal, fn); 43 | this.add(widget, listener); 44 | return listener; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/util/text.ts: -------------------------------------------------------------------------------- 1 | import GLib from "gi://GLib"; 2 | 3 | import type { ArtistRun } from "libmuse"; 4 | 5 | export function escape_label(label: string) { 6 | return GLib.markup_escape_text(label, -1); 7 | } 8 | 9 | export function indent_stack(stack: string) { 10 | return escape_label( 11 | stack 12 | .split("\n") 13 | .map((line) => ` ${escape_label(line)}`) 14 | .join("\n"), 15 | ); 16 | } 17 | 18 | export interface PrettySubtitleOptions { 19 | prefix?: string | (string | null)[]; 20 | suffix?: string | (string | null)[]; 21 | } 22 | 23 | function normalize_nodes(nodes?: string | (string | null)[]): string[] { 24 | if (nodes === undefined) { 25 | return []; 26 | } else if (typeof nodes === "string") { 27 | return [nodes]; 28 | } else { 29 | return nodes.filter((node) => node != null) as string[]; 30 | } 31 | } 32 | 33 | export function pretty_subtitles( 34 | artists: (string | null | ArtistRun)[], 35 | options: (string | null)[] | PrettySubtitleOptions = [], 36 | type: string | null = null, 37 | ) { 38 | const { prefix, suffix = [] } = Array.isArray(options) 39 | ? { suffix: normalize_nodes(options), prefix: [] } 40 | : { 41 | prefix: normalize_nodes(options.prefix), 42 | suffix: normalize_nodes(options.suffix), 43 | }; 44 | 45 | const author_markup: string[] = []; 46 | const author_plain: string[] = []; 47 | 48 | for (const node of artists) { 49 | if (is_artist_run(node)) { 50 | if (node.id) { 51 | author_markup.push( 52 | `${escape_label(node.name)}`, 55 | ); 56 | } else { 57 | author_markup.push(escape_label(node.name)); 58 | } 59 | author_plain.push(node.name); 60 | } else if (typeof node === "string") { 61 | author_markup.push(escape_label(node)); 62 | author_plain.push(node); 63 | } 64 | } 65 | 66 | const merge = (authors: string[]) => { 67 | const string = [ 68 | prefix.map(escape_label).join(" • "), 69 | authors.join(", "), 70 | suffix.map(escape_label).join(" • "), 71 | ] 72 | .filter(Boolean) 73 | .join(" • "); 74 | 75 | return type != null ? `${type} • ${string}` : string; 76 | }; 77 | 78 | return { markup: merge(author_markup), plain: merge(author_plain) }; 79 | } 80 | 81 | function is_artist_run(node: unknown): node is ArtistRun { 82 | return !!node && typeof node === "object" && "name" in node; 83 | } 84 | -------------------------------------------------------------------------------- /src/util/time.ts: -------------------------------------------------------------------------------- 1 | export function seconds_to_string(seconds: number) { 2 | // show the duration in the format "mm:ss" 3 | // show hours if the duration is longer than an hour 4 | 5 | const hours = Math.floor(seconds / 3600); 6 | const minutes = Math.floor(seconds / 60) % 60; 7 | seconds = Math.floor(seconds % 60); 8 | 9 | let string = ""; 10 | 11 | if (hours > 0) { 12 | string += hours.toString().padStart(2, "0") + "∶"; 13 | } 14 | 15 | string += minutes.toString().padStart(2, "0") + "∶"; 16 | 17 | string += seconds.toString().padStart(2, "0"); 18 | 19 | return string; 20 | } 21 | 22 | export function micro_to_seconds(micro: number) { 23 | return micro / 1000000; 24 | } 25 | 26 | export function micro_to_string(micro: number) { 27 | return seconds_to_string(micro_to_seconds(micro)); 28 | } 29 | -------------------------------------------------------------------------------- /src/util/volume.ts: -------------------------------------------------------------------------------- 1 | export function get_volume_icon_name(muted: boolean, volume: number) { 2 | let icon_name: string; 3 | 4 | if (muted) { 5 | icon_name = "audio-volume-muted-symbolic"; 6 | } else { 7 | if (volume === 0) { 8 | icon_name = "audio-volume-muted-symbolic"; 9 | } else if (volume < 0.33) { 10 | icon_name = "audio-volume-low-symbolic"; 11 | } else if (volume < 0.66) { 12 | icon_name = "audio-volume-medium-symbolic"; 13 | } else { 14 | icon_name = "audio-volume-high-symbolic"; 15 | } 16 | } 17 | 18 | return icon_name; 19 | } 20 | -------------------------------------------------------------------------------- /src/util/window.ts: -------------------------------------------------------------------------------- 1 | import Gtk from "gi://Gtk?version=4.0"; 2 | import Adw from "gi://Adw"; 3 | 4 | import { Application } from "src/application"; 5 | 6 | export function get_window() { 7 | // TODO: this will not always be defined 8 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 9 | return (Gtk.Application.get_default() as Application).window!; 10 | } 11 | 12 | export function add_toast(toast: string) { 13 | return get_window().add_toast(toast); 14 | } 15 | 16 | export function add_toast_full(toast: Adw.Toast) { 17 | return get_window().add_toast_full(toast); 18 | } 19 | -------------------------------------------------------------------------------- /subprojects/blueprint-compiler.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | directory = blueprint-compiler 3 | url = https://gitlab.gnome.org/jwestman/blueprint-compiler.git 4 | revision = v0.8.1 5 | depth = 1 6 | 7 | [provide] 8 | program_names = blueprint-compiler -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "ESNext", 5 | "lib": [ 6 | "ESNext", 7 | "DOM" 8 | ], 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "checkJs": true, 12 | "outDir": "_build", 13 | "strict": true, 14 | "moduleResolution": "Bundler", 15 | "baseUrl": ".", 16 | "paths": { 17 | "*": [ 18 | "*", 19 | "types/*", 20 | "gi-types/*" 21 | ], 22 | }, 23 | "typeRoots": [ 24 | "gi-types" 25 | ], 26 | "forceConsistentCasingInFileNames": true 27 | }, 28 | "include": [ 29 | "gi-types/gi.d.ts", 30 | "types/ambient.d.ts", 31 | "src/**/*" 32 | ], 33 | } 34 | -------------------------------------------------------------------------------- /types/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare function _(id: string): string; 2 | declare function C_(context: string, id: string): string; 3 | declare function print(args: string): void; 4 | declare function log(obj: object, others?: object[]): void; 5 | declare function log(msg: string, substitutions?: any[]): void; 6 | 7 | declare const pkg: { 8 | version: string; 9 | name: string; 10 | }; 11 | declare interface String { 12 | format(...replacements: string[]): string; 13 | format(...replacements: number[]): string; 14 | } 15 | declare interface Number { 16 | toFixed(digits: number): number; 17 | } 18 | 19 | declare module imports { 20 | const format: { 21 | format(this: String, ...args: any[]): string; 22 | printf(fmt: string, ...args: any[]): string; 23 | vprintf(fmt: string, args: any[]): string; 24 | }; 25 | } 26 | 27 | declare module "gettext" { 28 | export function gettext(id: string): string; 29 | export function ngettext( 30 | singular: string, 31 | plural: string, 32 | n: number, 33 | ): string; 34 | } 35 | -------------------------------------------------------------------------------- /types/gettext.d.ts: -------------------------------------------------------------------------------- 1 | export namespace Gettext { 2 | export enum LocaleCategory { 3 | CTYPE = 0, 4 | NUMERIC = 1, 5 | TIME = 2, 6 | COLLATE = 3, 7 | MONETARY = 4, 8 | MESSAGES = 5, 9 | ALL = 6, 10 | } 11 | 12 | export function setlocale( 13 | category: LocaleCategory, 14 | locale: string | null, 15 | ): string | null; 16 | export function textdomain(domainName: string): void; 17 | export function bindtextdomain(domainName: string, dirName: string): void; 18 | 19 | export function gettext(msgid: string): string; 20 | export function dgettext(domainName: string | null, msgid: string): string; 21 | export function dcgettext( 22 | domainName: string | null, 23 | msgid: string, 24 | category: LocaleCategory, 25 | ): string; 26 | 27 | export function ngettext(msgid1: string, msgid2: string, n: number): string; 28 | export function dngettext( 29 | domainName: string | null, 30 | msgid1: string, 31 | msgid2: string, 32 | n: number, 33 | ): string; 34 | 35 | export function pgettext(context: string | null, msgid: string): string; 36 | export function dpgettext( 37 | domainName: string | null, 38 | context: string | null, 39 | msgid: string, 40 | ): string; 41 | 42 | export class GettextObject { 43 | gettext(msgid: string): string; 44 | ngettext(msgid1: string, msgid2: string, n: number): string; 45 | pgettext(context: string | null, msgid: string): string; 46 | } 47 | 48 | export function domain(domainName: string | null): GettextObject; 49 | } 50 | 51 | export default Gettext; 52 | --------------------------------------------------------------------------------