Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
radio rust tokio web-radio command-line-tool tui

Compare changes

Choose any two refs to compare.

+1 -1
.fluentci/mod.ts
··· 1 - export * from "./src/dagger/index.ts";
··· 1 + export * from "./src/mod.ts";
-39
.fluentci/src/aws/README.md
··· 1 - # AWS CodePipeline 2 - 3 - [![fluentci pipeline](https://img.shields.io/badge/dynamic/json?label=pkg.fluentci.io&labelColor=%23000&color=%23460cf1&url=https%3A%2F%2Fapi.fluentci.io%2Fv1%2Fpipeline%2Frust_pipeline&query=%24.version)](https://pkg.fluentci.io/rust_pipeline) 4 - [![deno module](https://shield.deno.dev/x/rust_pipeline)](https://deno.land/x/rust_pipeline) 5 - ![deno compatibility](https://shield.deno.dev/deno/^1.34) 6 - [![](https://img.shields.io/codecov/c/gh/fluent-ci-templates/rust-pipeline)](https://codecov.io/gh/fluent-ci-templates/rust-pipeline) 7 - 8 - The following command will generate a `buildspec.yml` file in your project: 9 - 10 - ```bash 11 - fluentci ac init -t rust_pipeline 12 - ``` 13 - 14 - Generated file: 15 - 16 - ```yaml 17 - # Do not edit this file directly. It is generated by https://deno.land/x/fluent_aws_codepipeline 18 - 19 - version: 0.2 20 - phases: 21 - install: 22 - commands: 23 - - curl -fsSL https://deno.land/x/install/install.sh | sh 24 - - export DENO_INSTALL="$HOME/.deno" 25 - - export PATH="$DENO_INSTALL/bin:$PATH" 26 - - deno install -A -r https://cli.fluentci.io -n fluentci 27 - - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 28 - - mv bin/dagger /usr/local/bin 29 - - dagger version 30 - build: 31 - commands: 32 - - fluentci run rust_pipeline test build 33 - post_build: 34 - commands: 35 - - echo Build completed on `date` 36 - 37 - ``` 38 - 39 - Feel free to edit the template generator at `.fluentci/src/aws/config.ts` to your needs.
···
-24
.fluentci/src/aws/config.ts
··· 1 - import { BuildSpec } from "fluent_aws_codepipeline"; 2 - 3 - export function generateYaml(): BuildSpec { 4 - const buildspec = new BuildSpec(); 5 - buildspec 6 - .phase("install", { 7 - commands: [ 8 - "curl -fsSL https://deno.land/x/install/install.sh | sh", 9 - 'export DENO_INSTALL="$HOME/.deno"', 10 - 'export PATH="$DENO_INSTALL/bin:$PATH"', 11 - "deno install -A -r https://cli.fluentci.io -n fluentci", 12 - "curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh", 13 - "mv bin/dagger /usr/local/bin", 14 - "dagger version", 15 - ], 16 - }) 17 - .phase("build", { 18 - commands: ["fluentci run rust_pipeline test build"], 19 - }) 20 - .phase("post_build", { 21 - commands: ["echo Build completed on `date`"], 22 - }); 23 - return buildspec; 24 - }
···
-9
.fluentci/src/aws/config_test.ts
··· 1 - import { assertEquals } from "https://deno.land/std@0.191.0/testing/asserts.ts"; 2 - import { generateYaml } from "./config.ts"; 3 - 4 - Deno.test(function generateAWSCodePipelineTest() { 5 - const buildspec = generateYaml(); 6 - const actual = buildspec.toString(); 7 - const expected = Deno.readTextFileSync("./fixtures/buildspec.yml"); 8 - assertEquals(actual, expected); 9 - });
···
-3
.fluentci/src/aws/init.ts
··· 1 - import { generateYaml } from "./config.ts"; 2 - 3 - generateYaml().save("buildspec.yml");
···
-42
.fluentci/src/azure/README.md
··· 1 - # Azure Pipelines 2 - 3 - [![fluentci pipeline](https://img.shields.io/badge/dynamic/json?label=pkg.fluentci.io&labelColor=%23000&color=%23460cf1&url=https%3A%2F%2Fapi.fluentci.io%2Fv1%2Fpipeline%2Frust_pipeline&query=%24.version)](https://pkg.fluentci.io/rust_pipeline) 4 - [![deno module](https://shield.deno.dev/x/rust_pipeline)](https://deno.land/x/rust_pipeline) 5 - ![deno compatibility](https://shield.deno.dev/deno/^1.34) 6 - [![](https://img.shields.io/codecov/c/gh/fluent-ci-templates/rust-pipeline)](https://codecov.io/gh/fluent-ci-templates/rust-pipeline) 7 - 8 - The following command will generate a `azure-pipelines.yml` file in your project: 9 - 10 - ```bash 11 - fluentci ap init -t rust_pipeline 12 - ``` 13 - 14 - Generated file: 15 - 16 - ```yaml 17 - # Do not edit this file directly. It is generated by https://deno.land/x/fluent_azure_pipelines 18 - 19 - trigger: 20 - - main 21 - pool: 22 - name: Default 23 - vmImage: ubuntu-latest 24 - steps: 25 - - script: | 26 - curl -fsSL https://deno.land/x/install/install.sh | sh 27 - export DENO_INSTALL="$HOME/.deno" 28 - export PATH="$DENO_INSTALL/bin:$PATH" 29 - displayName: Install Deno 30 - - script: deno install -A -r https://cli.fluentci.io -n fluentci 31 - displayName: Setup Fluent CI CLI 32 - - script: | 33 - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 34 - sudo mv bin/dagger /usr/local/bin 35 - dagger version 36 - displayName: Setup Dagger 37 - - script: fluentci run rust_pipeline test build 38 - displayName: Run Dagger Pipelines 39 - 40 - ``` 41 - 42 - Feel free to edit the template generator at `.fluentci/src/azure/config.ts` to your needs.
···
-41
.fluentci/src/azure/config.ts
··· 1 - import { AzurePipeline } from "fluent_azure_pipelines"; 2 - 3 - export function generateYaml(): AzurePipeline { 4 - const azurePipeline = new AzurePipeline(); 5 - 6 - const installDeno = `\ 7 - curl -fsSL https://deno.land/x/install/install.sh | sh 8 - export DENO_INSTALL="$HOME/.deno" 9 - export PATH="$DENO_INSTALL/bin:$PATH" 10 - `; 11 - 12 - const setupDagger = `\ 13 - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 14 - sudo mv bin/dagger /usr/local/bin 15 - dagger version 16 - `; 17 - 18 - azurePipeline 19 - .trigger(["main"]) 20 - .pool({ 21 - name: "Default", 22 - vmImage: "ubuntu-latest", 23 - }) 24 - .step({ 25 - script: installDeno, 26 - displayName: "Install Deno", 27 - }) 28 - .step({ 29 - script: "deno install -A -r https://cli.fluentci.io -n fluentci", 30 - displayName: "Setup Fluent CI CLI", 31 - }) 32 - .step({ 33 - script: setupDagger, 34 - displayName: "Setup Dagger", 35 - }) 36 - .step({ 37 - script: "fluentci run rust_pipeline test build", 38 - displayName: "Run Dagger Pipelines", 39 - }); 40 - return azurePipeline; 41 - }
···
-9
.fluentci/src/azure/config_test.ts
··· 1 - import { assertEquals } from "https://deno.land/std@0.191.0/testing/asserts.ts"; 2 - import { generateYaml } from "./config.ts"; 3 - 4 - Deno.test(function generateAzurePipelinesTest() { 5 - const azurepipelines = generateYaml(); 6 - const actual = azurepipelines.toString(); 7 - const expected = Deno.readTextFileSync("./fixtures/azure-pipelines.yml"); 8 - assertEquals(actual, expected); 9 - });
···
-3
.fluentci/src/azure/init.ts
··· 1 - import { generateYaml } from "./config.ts"; 2 - 3 - generateYaml().save("azure-pipeline.yml");
···
-47
.fluentci/src/circleci/README.md
··· 1 - # Circle CI 2 - 3 - [![fluentci pipeline](https://img.shields.io/badge/dynamic/json?label=pkg.fluentci.io&labelColor=%23000&color=%23460cf1&url=https%3A%2F%2Fapi.fluentci.io%2Fv1%2Fpipeline%2Frust_pipeline&query=%24.version)](https://pkg.fluentci.io/rust_pipeline) 4 - [![deno module](https://shield.deno.dev/x/rust_pipeline)](https://deno.land/x/rust_pipeline) 5 - ![deno compatibility](https://shield.deno.dev/deno/^1.34) 6 - [![](https://img.shields.io/codecov/c/gh/fluent-ci-templates/rust-pipeline)](https://codecov.io/gh/fluent-ci-templates/rust-pipeline) 7 - 8 - 9 - The following command will generate a `.circleci/config.yml` file in your project: 10 - 11 - ```bash 12 - fluentci cci init -t rust_pipeline 13 - ``` 14 - 15 - Generated file: 16 - 17 - ```yaml 18 - # Do not edit this file directly. It is generated by https://deno.land/x/fluent_circleci 19 - 20 - version: 2.1 21 - jobs: 22 - tests: 23 - steps: 24 - - checkout 25 - - run: sudo apt-get update && sudo apt-get install -y curl unzip 26 - - run: | 27 - curl -fsSL https://deno.land/x/install/install.sh | sh 28 - export DENO_INSTALL="$HOME/.deno" 29 - export PATH="$DENO_INSTALL/bin:$PATH" 30 - - run: deno install -A -r https://cli.fluentci.io -n fluentci 31 - - run: | 32 - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 33 - sudo mv bin/dagger /usr/local/bin 34 - dagger version 35 - - run: 36 - name: Upload Coverage 37 - command: fluentci run rust_pipeline test build 38 - machine: 39 - image: ubuntu-2004:2023.07.1 40 - workflows: 41 - dagger: 42 - jobs: 43 - - tests 44 - 45 - ``` 46 - 47 - Feel free to edit the template generator at `.fluentci/src/circleci/config.ts` to your needs.
···
-37
.fluentci/src/circleci/config.ts
··· 1 - import { CircleCI, Job } from "fluent_circleci"; 2 - 3 - export function generateYaml(): CircleCI { 4 - const circleci = new CircleCI(); 5 - 6 - const tests = new Job().machine({ image: "ubuntu-2004:2023.07.1" }).steps([ 7 - "checkout", 8 - { 9 - run: "sudo apt-get update && sudo apt-get install -y curl unzip", 10 - }, 11 - { 12 - run: `\ 13 - curl -fsSL https://deno.land/x/install/install.sh | sh 14 - export DENO_INSTALL="$HOME/.deno" 15 - export PATH="$DENO_INSTALL/bin:$PATH"`, 16 - }, 17 - { 18 - run: "deno install -A -r https://cli.fluentci.io -n fluentci", 19 - }, 20 - { 21 - run: `\ 22 - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 23 - sudo mv bin/dagger /usr/local/bin 24 - dagger version`, 25 - }, 26 - { 27 - run: { 28 - name: "Run Dagger Pipelines", 29 - command: "fluentci run rust_pipeline test build", 30 - }, 31 - }, 32 - ]); 33 - 34 - circleci.jobs({ tests }).workflow("dagger", ["tests"]); 35 - 36 - return circleci; 37 - }
···
-9
.fluentci/src/circleci/config_test.ts
··· 1 - import { assertEquals } from "https://deno.land/std@0.191.0/testing/asserts.ts"; 2 - import { generateYaml } from "./config.ts"; 3 - 4 - Deno.test(function generateCircleCITest() { 5 - const circleci = generateYaml(); 6 - const actual = circleci.toString(); 7 - const expected = Deno.readTextFileSync("./fixtures/config.yml"); 8 - assertEquals(actual, expected); 9 - });
···
-3
.fluentci/src/circleci/init.ts
··· 1 - import { generateYaml } from "./config.ts"; 2 - 3 - generateYaml().save(".circleci/config.yml");
···
-4
.fluentci/src/dagger/index.ts
··· 1 - import pipeline from "./pipeline.ts"; 2 - import { build, test, jobDescriptions } from "./jobs.ts"; 3 - 4 - export { pipeline, build, test, jobDescriptions };
···
-177
.fluentci/src/dagger/jobs.ts
··· 1 - import { dag } from "../../sdk/client.gen.ts"; 2 - import { buildRustFlags, getDirectory } from "./lib.ts"; 3 - 4 - export enum Job { 5 - test = "test", 6 - build = "build", 7 - } 8 - 9 - export const exclude = ["target", ".git", ".devbox", ".fluentci"]; 10 - 11 - export const test = async (src = ".", options: string[] = []) => { 12 - const context = await getDirectory(src); 13 - const ctr = dag 14 - .container() 15 - .from("rust:1.89-bullseye") 16 - .withDirectory("/app", context, { exclude }) 17 - .withWorkdir("/app") 18 - .withMountedCache("/app/target", dag.cacheVolume("target")) 19 - .withMountedCache("/root/cargo/registry", dag.cacheVolume("registry")) 20 - .withExec(["cargo", "test", ...options]); 21 - 22 - return ctr.stdout(); 23 - }; 24 - 25 - export const build = async (src = ".") => { 26 - const rustflags = buildRustFlags(); 27 - const context = await getDirectory(src); 28 - const ctr = dag 29 - .container() 30 - .from("rust:1.89-bullseye") 31 - .withExec(["dpkg", "--add-architecture", "armhf"]) 32 - .withExec(["dpkg", "--add-architecture", "arm64"]) 33 - .withExec(["apt-get", "update"]) 34 - .withExec([ 35 - "apt-get", 36 - "install", 37 - "-y", 38 - "build-essential", 39 - "libasound2-dev", 40 - "protobuf-compiler", 41 - ]) 42 - .withExec([ 43 - "apt-get", 44 - "install", 45 - "-y", 46 - "-qq", 47 - "gcc-arm-linux-gnueabihf", 48 - "libc6-armhf-cross", 49 - "libc6-dev-armhf-cross", 50 - "gcc-aarch64-linux-gnu", 51 - "libc6-arm64-cross", 52 - "libc6-dev-arm64-cross", 53 - "libc6-armel-cross", 54 - "libc6-dev-armel-cross", 55 - "binutils-arm-linux-gnueabi", 56 - "gcc-arm-linux-gnueabi", 57 - "libncurses5-dev", 58 - "bison", 59 - "flex", 60 - "libssl-dev", 61 - "bc", 62 - "pkg-config", 63 - "libudev-dev", 64 - ]) 65 - .withExec(["mkdir", "-p", "/build/sysroot"]) 66 - .withExec([ 67 - "apt-get", 68 - "download", 69 - "libasound2:armhf", 70 - "libasound2-dev:armhf", 71 - "libasound2:arm64", 72 - "libasound2-dev:arm64", 73 - ]) 74 - .withExec([ 75 - "dpkg", 76 - "-x", 77 - "libasound2-dev_1.2.4-1.1_arm64.deb", 78 - "/build/sysroot/", 79 - ]) 80 - .withExec([ 81 - "dpkg", 82 - "-x", 83 - "libasound2_1.2.4-1.1_arm64.deb", 84 - "/build/sysroot/", 85 - ]) 86 - .withExec([ 87 - "dpkg", 88 - "-x", 89 - "libasound2-dev_1.2.4-1.1_armhf.deb", 90 - "/build/sysroot/", 91 - ]) 92 - .withExec([ 93 - "dpkg", 94 - "-x", 95 - "libasound2_1.2.4-1.1_armhf.deb", 96 - "/build/sysroot/", 97 - ]) 98 - .withDirectory("/app", context, { exclude }) 99 - .withWorkdir("/app") 100 - .withMountedCache("/app/target", dag.cacheVolume("target")) 101 - .withMountedCache("/root/cargo/registry", dag.cacheVolume("registry")) 102 - .withMountedCache("/assets", dag.cacheVolume("gh-release-assets")) 103 - .withEnvVariable("RUSTFLAGS", rustflags) 104 - .withEnvVariable( 105 - "PKG_CONFIG_ALLOW_CROSS", 106 - Deno.env.get("TARGET") !== "x86_64-unknown-linux-gnu" ? "1" : "0" 107 - ) 108 - .withEnvVariable( 109 - "C_INCLUDE_PATH", 110 - Deno.env.get("TARGET") !== "x86_64-unknown-linux-gnu" 111 - ? "/build/sysroot/usr/include" 112 - : "/usr/include" 113 - ) 114 - .withEnvVariable("TAG", Deno.env.get("TAG") || "latest") 115 - .withEnvVariable( 116 - "TARGET", 117 - Deno.env.get("TARGET") || "x86_64-unknown-linux-gnu" 118 - ) 119 - .withExec([ 120 - "sh", 121 - "-c", 122 - "mv /usr/bin/protoc /usr/bin/_protoc && cp tools/protoc /usr/bin/protoc && chmod a+x /usr/bin/protoc", 123 - ]) 124 - .withExec(["sh", "-c", "rustup target add $TARGET"]) 125 - .withExec(["sh", "-c", "cargo build --release --target $TARGET"]) 126 - .withExec(["sh", "-c", "cp target/${TARGET}/release/tunein ."]) 127 - .withExec([ 128 - "sh", 129 - "-c", 130 - "tar czvf /assets/tunein_${TAG}_${TARGET}.tar.gz tunein README.md LICENSE", 131 - ]) 132 - .withExec([ 133 - "sh", 134 - "-c", 135 - "shasum -a 256 /assets/tunein_${TAG}_${TARGET}.tar.gz > /assets/tunein_${TAG}_${TARGET}.tar.gz.sha256", 136 - ]) 137 - .withExec(["sh", "-c", "cp /assets/tunein_${TAG}_${TARGET}.tar.gz ."]) 138 - .withExec([ 139 - "sh", 140 - "-c", 141 - "cp /assets/tunein_${TAG}_${TARGET}.tar.gz.sha256 .", 142 - ]); 143 - 144 - const exe = await ctr.file( 145 - `/app/tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz` 146 - ); 147 - await exe.export( 148 - `./tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz` 149 - ); 150 - 151 - const sha = await ctr.file( 152 - `/app/tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz.sha256` 153 - ); 154 - await sha.export( 155 - `./tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz.sha256` 156 - ); 157 - return ctr.stdout(); 158 - }; 159 - 160 - export type JobExec = (src?: string) => 161 - | Promise<string> 162 - | (( 163 - src?: string, 164 - options?: { 165 - ignore: string[]; 166 - } 167 - ) => Promise<string>); 168 - 169 - export const runnableJobs: Record<Job, JobExec> = { 170 - [Job.test]: test, 171 - [Job.build]: build, 172 - }; 173 - 174 - export const jobDescriptions: Record<Job, string> = { 175 - [Job.test]: "Run tests", 176 - [Job.build]: "Build the project", 177 - };
···
-45
.fluentci/src/dagger/lib.ts
··· 1 - import { dag } from "../../sdk/client.gen.ts"; 2 - import { Directory, DirectoryID } from "../../deps.ts"; 3 - 4 - export const getDirectory = async ( 5 - src: string | Directory | undefined = "." 6 - ) => { 7 - if (src instanceof Directory) { 8 - return src; 9 - } 10 - if (typeof src === "string") { 11 - try { 12 - const directory = dag.loadDirectoryFromID(src as DirectoryID); 13 - await directory.id(); 14 - return directory; 15 - } catch (_) { 16 - return dag.host 17 - ? dag.host().directory(src) 18 - : dag.currentModule().source().directory(src); 19 - } 20 - } 21 - return dag.host 22 - ? dag.host().directory(src) 23 - : dag.currentModule().source().directory(src); 24 - }; 25 - 26 - export function buildRustFlags(): string { 27 - let rustflags = ""; 28 - switch (Deno.env.get("TARGET")) { 29 - case "aarch64-unknown-linux-gnu": 30 - rustflags = `-C linker=aarch64-linux-gnu-gcc \ 31 - -L/usr/aarch64-linux-gnu/lib \ 32 - -L/build/sysroot/usr/lib/aarch64-linux-gnu \ 33 - -L/build/sysroot/lib/aarch64-linux-gnu`; 34 - break; 35 - case "armv7-unknown-linux-gnueabihf": 36 - rustflags = `-C linker=arm-linux-gnueabihf-gcc \ 37 - -L/usr/arm-linux-gnueabihf/lib \ 38 - -L/build/sysroot/usr/lib/arm-linux-gnueabihf \ 39 - -L/build/sysroot/lib/arm-linux-gnueabihf`; 40 - break; 41 - default: 42 - break; 43 - } 44 - return rustflags; 45 - }
···
-20
.fluentci/src/dagger/list_jobs.ts
··· 1 - import { brightGreen, stringifyTree } from "../../deps.ts"; 2 - import { runnableJobs, jobDescriptions, Job } from "./jobs.ts"; 3 - 4 - const tree = { 5 - name: brightGreen("rust_pipeline"), 6 - children: (Object.keys(runnableJobs) as Job[]).map((job) => ({ 7 - name: jobDescriptions[job] 8 - ? `${brightGreen(job)} - ${jobDescriptions[job]}` 9 - : brightGreen(job), 10 - children: [], 11 - })), 12 - }; 13 - 14 - console.log( 15 - stringifyTree( 16 - tree, 17 - (t) => t.name, 18 - (t) => t.children 19 - ) 20 - );
···
-24
.fluentci/src/dagger/pipeline.ts
··· 1 - import * as jobs from "./jobs.ts"; 2 - 3 - const { build, test } = jobs; 4 - 5 - export default async function pipeline(src = ".", args: string[] = []) { 6 - if (args.length > 0) { 7 - await runSpecificJobs(args); 8 - return; 9 - } 10 - 11 - await test(src); 12 - await build(src); 13 - } 14 - 15 - async function runSpecificJobs(args: string[]) { 16 - for (const name of args) { 17 - // deno-lint-ignore no-explicit-any 18 - const job = (jobs as any)[name]; 19 - if (!job) { 20 - throw new Error(`Job ${name} not found`); 21 - } 22 - await job(); 23 - } 24 - }
···
-3
.fluentci/src/dagger/runner.ts
··· 1 - import pipeline from "./pipeline.ts"; 2 - 3 - await pipeline(".", Deno.args);
···
-50
.fluentci/src/github/README.md
··· 1 - # Github Actions 2 - 3 - [![fluentci pipeline](https://img.shields.io/badge/dynamic/json?label=pkg.fluentci.io&labelColor=%23000&color=%23460cf1&url=https%3A%2F%2Fapi.fluentci.io%2Fv1%2Fpipeline%2Frust_pipeline&query=%24.version)](https://pkg.fluentci.io/rust_pipeline) 4 - [![deno module](https://shield.deno.dev/x/rust_pipeline)](https://deno.land/x/rust_pipeline) 5 - ![deno compatibility](https://shield.deno.dev/deno/^1.34) 6 - [![](https://img.shields.io/codecov/c/gh/fluent-ci-templates/rust-pipeline)](https://codecov.io/gh/fluent-ci-templates/rust-pipeline) 7 - 8 - The following command will generate a `.github/workflows/tests.yml` file in your project: 9 - 10 - ```bash 11 - fluentci gh init -t rust_pipeline 12 - ``` 13 - 14 - Or, if you already have a `.fluentci` folder (generated from `fluentci init -t rust`) in your project: 15 - 16 - ```bash 17 - fluentci gh init 18 - ``` 19 - 20 - Generated file: 21 - 22 - ```yaml 23 - # Do not edit this file directly. It is generated by https://deno.land/x/fluent_github_actions 24 - 25 - name: Test 26 - on: 27 - push: 28 - branches: 29 - - main 30 - jobs: 31 - test: 32 - runs-on: ubuntu-latest 33 - steps: 34 - - uses: actions/checkout@v2 35 - - uses: denoland/setup-deno@v1 36 - with: 37 - deno-version: v1.37 38 - - name: Setup Fluent CI CLI 39 - run: deno install -A -r https://cli.fluentci.io -n fluentci 40 - - name: Setup Dagger 41 - run: | 42 - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 43 - sudo mv bin/dagger /usr/local/bin 44 - dagger version 45 - - name: Run Tests and Build 46 - run: fluentci run rust_pipeline test build 47 - 48 - ``` 49 - 50 - Feel free to edit the template generator at `.fluentci/src/github/config.ts` to your needs.
···
-45
.fluentci/src/github/config.ts
··· 1 - import { JobSpec, Workflow } from "fluent_github_actions"; 2 - 3 - export function generateYaml(): Workflow { 4 - const workflow = new Workflow("Test"); 5 - 6 - const push = { 7 - branches: ["main"], 8 - }; 9 - 10 - const setupDagger = `\ 11 - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 12 - sudo mv bin/dagger /usr/local/bin 13 - dagger version`; 14 - 15 - const test: JobSpec = { 16 - "runs-on": "ubuntu-latest", 17 - steps: [ 18 - { 19 - uses: "actions/checkout@v2", 20 - }, 21 - { 22 - uses: "denoland/setup-deno@v1", 23 - with: { 24 - "deno-version": "v1.37", 25 - }, 26 - }, 27 - { 28 - name: "Setup Fluent CI CLI", 29 - run: "deno install -A -r https://cli.fluentci.io -n fluentci", 30 - }, 31 - { 32 - name: "Setup Dagger", 33 - run: setupDagger, 34 - }, 35 - { 36 - name: "Run Tests and Build", 37 - run: "fluentci run rust_pipeline test build", 38 - }, 39 - ], 40 - }; 41 - 42 - workflow.on({ push }).jobs({ test }); 43 - 44 - return workflow; 45 - }
···
-9
.fluentci/src/github/config_test.ts
··· 1 - import { assertEquals } from "https://deno.land/std@0.191.0/testing/asserts.ts"; 2 - import { generateYaml } from "./config.ts"; 3 - 4 - Deno.test(function generateGithubActionsWorkflowTest() { 5 - const workflow = generateYaml(); 6 - const actual = workflow.toString(); 7 - const expected = Deno.readTextFileSync("./fixtures/workflow.yml"); 8 - assertEquals(actual, expected); 9 - });
···
-3
.fluentci/src/github/init.ts
··· 1 - import { generateYaml } from "./config.ts"; 2 - 3 - generateYaml().save(".github/workflows/tests.yml");
···
-48
.fluentci/src/gitlab/README.md
··· 1 - # Gitlab CI 2 - 3 - [![fluentci pipeline](https://img.shields.io/badge/dynamic/json?label=pkg.fluentci.io&labelColor=%23000&color=%23460cf1&url=https%3A%2F%2Fapi.fluentci.io%2Fv1%2Fpipeline%2Frust_pipeline&query=%24.version)](https://pkg.fluentci.io/rust_pipeline) 4 - [![deno module](https://shield.deno.dev/x/rust_pipeline)](https://deno.land/x/rust_pipeline) 5 - ![deno compatibility](https://shield.deno.dev/deno/^1.34) 6 - [![](https://img.shields.io/codecov/c/gh/fluent-ci-templates/rust-pipeline)](https://codecov.io/gh/fluent-ci-templates/rust-pipeline) 7 - 8 - The following command will generate a `.gitlab-ci.yml` file in your project: 9 - 10 - ```bash 11 - fluentci gl init -t rust_pipeline 12 - ``` 13 - 14 - Generated file: 15 - 16 - ```yaml 17 - 18 - # Do not edit this file directly. It is generated by https://deno.land/x/fluent_gitlab_ci 19 - 20 - .docker: 21 - image: denoland/deno:alpine 22 - services: 23 - - docker:${DOCKER_VERSION}-dind 24 - variables: 25 - DOCKER_HOST: tcp://docker:2376 26 - DOCKER_TLS_VERIFY: "1" 27 - DOCKER_TLS_CERTDIR: /certs 28 - DOCKER_CERT_PATH: /certs/client 29 - DOCKER_DRIVER: overlay2 30 - DOCKER_VERSION: 20.10.16 31 - 32 - .dagger: 33 - extends: .docker 34 - before_script: 35 - - apk add docker-cli curl unzip 36 - - deno install -A -r https://cli.fluentci.io -n fluentci 37 - - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 38 - - mv bin/dagger /usr/local/bin 39 - - dagger version 40 - 41 - tests: 42 - extends: .dagger 43 - script: 44 - - fluentci run rust_pipeline test build 45 - 46 - ``` 47 - 48 - Feel free to edit the template generator at `.fluentci/src/gitlab/config.ts` to your needs.
···
-34
.fluentci/src/gitlab/config.ts
··· 1 - import { GitlabCI, Job } from "fluent_gitlab_ci"; 2 - 3 - export function generateYaml(): GitlabCI { 4 - const docker = new Job() 5 - .image("denoland/deno:alpine") 6 - .services(["docker:${DOCKER_VERSION}-dind"]) 7 - .variables({ 8 - DOCKER_HOST: "tcp://docker:2376", 9 - DOCKER_TLS_VERIFY: "1", 10 - DOCKER_TLS_CERTDIR: "/certs", 11 - DOCKER_CERT_PATH: "/certs/client", 12 - DOCKER_DRIVER: "overlay2", 13 - DOCKER_VERSION: "20.10.16", 14 - }); 15 - 16 - const dagger = new Job().extends(".docker").beforeScript( 17 - ` 18 - apk add docker-cli curl unzip 19 - deno install -A -r https://cli.fluentci.io -n fluentci 20 - curl -L https://dl.dagger.io/dagger/install.sh | DAGGER_VERSION=0.8.1 sh 21 - mv bin/dagger /usr/local/bin 22 - dagger version 23 - ` 24 - ); 25 - 26 - const tests = new Job() 27 - .extends(".dagger") 28 - .script("fluentci run rust_pipeline test build"); 29 - 30 - return new GitlabCI() 31 - .addJob(".docker", docker) 32 - .addJob(".dagger", dagger) 33 - .addJob("tests", tests); 34 - }
···
-9
.fluentci/src/gitlab/config_test.ts
··· 1 - import { assertEquals } from "https://deno.land/std@0.191.0/testing/asserts.ts"; 2 - import { generateYaml } from "./config.ts"; 3 - 4 - Deno.test(function generateGitlabCITest() { 5 - const gitlabci = generateYaml(); 6 - const actual = gitlabci.toString(); 7 - const expected = Deno.readTextFileSync("./fixtures/.gitlab-ci.yml"); 8 - assertEquals(actual, expected); 9 - });
···
-3
.fluentci/src/gitlab/init.ts
··· 1 - import { generateYaml } from "./config.ts"; 2 - 3 - generateYaml().write();
···
+434
.fluentci/src/jobs.ts
···
··· 1 + import { dag } from "../sdk/client.gen.ts"; 2 + import { buildRustFlags, getDirectory } from "./lib.ts"; 3 + 4 + export enum Job { 5 + test = "test", 6 + build = "build", 7 + } 8 + 9 + export const exclude = ["target", ".git", ".devbox", ".fluentci"]; 10 + 11 + export const test = async (src = ".", options: string[] = []) => { 12 + const context = await getDirectory(src); 13 + const ctr = dag 14 + .container() 15 + .from("rust:1.89-bullseye") 16 + .withDirectory("/app", context, { exclude }) 17 + .withWorkdir("/app") 18 + .withMountedCache("/app/target", dag.cacheVolume("target")) 19 + .withMountedCache("/root/cargo/registry", dag.cacheVolume("registry")) 20 + .withExec(["cargo", "test", ...options]); 21 + 22 + return ctr.stdout(); 23 + }; 24 + 25 + export const build = async (src = ".") => { 26 + const rustflags = buildRustFlags(); 27 + const context = await getDirectory(src); 28 + const ctr = dag 29 + .container() 30 + .from("rust:1.89-bullseye") 31 + .withExec(["dpkg", "--add-architecture", "armhf"]) 32 + .withExec(["dpkg", "--add-architecture", "arm64"]) 33 + .withExec(["apt-get", "update"]) 34 + .withExec([ 35 + "apt-get", 36 + "install", 37 + "-y", 38 + "build-essential", 39 + "libasound2-dev", 40 + "protobuf-compiler", 41 + ]) 42 + .withExec([ 43 + "apt-get", 44 + "install", 45 + "-y", 46 + "-qq", 47 + "gcc-arm-linux-gnueabihf", 48 + "libc6-armhf-cross", 49 + "libc6-dev-armhf-cross", 50 + "gcc-aarch64-linux-gnu", 51 + "libc6-arm64-cross", 52 + "libc6-dev-arm64-cross", 53 + "libc6-armel-cross", 54 + "libc6-dev-armel-cross", 55 + "binutils-arm-linux-gnueabi", 56 + "gcc-arm-linux-gnueabi", 57 + "libncurses5-dev", 58 + "bison", 59 + "flex", 60 + "libssl-dev", 61 + "bc", 62 + "pkg-config", 63 + "libudev-dev", 64 + "libdbus-1-dev", 65 + ]) 66 + .withExec(["mkdir", "-p", "/build/sysroot"]) 67 + .withExec([ 68 + "apt-get", 69 + "download", 70 + "libasound2:armhf", 71 + "libasound2-dev:armhf", 72 + "libdbus-1-dev:armhf", 73 + "libdbus-1-3:armhf", 74 + "libsystemd-dev:armhf", 75 + "libsystemd0:armhf", 76 + "libcap2:armhf", 77 + "libcap-dev:armhf", 78 + "libgcrypt20:armhf", 79 + "libgcrypt20-dev:armhf", 80 + "libgpg-error0:armhf", 81 + "libgpg-error-dev:armhf", 82 + "liblz4-1:armhf", 83 + "liblz4-dev:armhf", 84 + "libxxhash0:armhf", 85 + "libxxhash-dev:armhf", 86 + "liblzma5:armhf", 87 + "liblzma-dev:armhf", 88 + "libzstd1:armhf", 89 + "libzstd-dev:armhf", 90 + 91 + "libasound2:arm64", 92 + "libasound2-dev:arm64", 93 + "libdbus-1-dev:arm64", 94 + "libdbus-1-3:arm64", 95 + "libsystemd-dev:arm64", 96 + "libsystemd0:arm64", 97 + "libcap2:arm64", 98 + "libcap-dev:arm64", 99 + "libgcrypt20:arm64", 100 + "libgcrypt20-dev:arm64", 101 + "libgpg-error0:arm64", 102 + "libgpg-error-dev:arm64", 103 + "liblz4-1:arm64", 104 + "liblz4-dev:arm64", 105 + "libxxhash0:arm64", 106 + "libxxhash-dev:arm64", 107 + "liblzma5:arm64", 108 + "liblzma-dev:arm64", 109 + "libzstd1:arm64", 110 + "libzstd-dev:arm64", 111 + ]) 112 + .withExec([ 113 + "dpkg", 114 + "-x", 115 + "libasound2-dev_1.2.4-1.1_arm64.deb", 116 + "/build/sysroot/", 117 + ]) 118 + .withExec([ 119 + "dpkg", 120 + "-x", 121 + "libasound2_1.2.4-1.1_arm64.deb", 122 + "/build/sysroot/", 123 + ]) 124 + .withExec([ 125 + "dpkg", 126 + "-x", 127 + "libasound2-dev_1.2.4-1.1_armhf.deb", 128 + "/build/sysroot/", 129 + ]) 130 + .withExec([ 131 + "dpkg", 132 + "-x", 133 + "libasound2_1.2.4-1.1_armhf.deb", 134 + "/build/sysroot/", 135 + ]) 136 + .withExec([ 137 + "dpkg", 138 + "-x", 139 + "libdbus-1-dev_1.12.28-0+deb11u1_armhf.deb", 140 + "/build/sysroot/", 141 + ]) 142 + .withExec([ 143 + "dpkg", 144 + "-x", 145 + "libdbus-1-3_1.12.28-0+deb11u1_armhf.deb", 146 + "/build/sysroot/", 147 + ]) 148 + .withExec([ 149 + "dpkg", 150 + "-x", 151 + "libsystemd-dev_247.3-7+deb11u7_armhf.deb", 152 + "/build/sysroot/", 153 + ]) 154 + .withExec([ 155 + "dpkg", 156 + "-x", 157 + "libsystemd0_247.3-7+deb11u7_armhf.deb", 158 + "/build/sysroot/", 159 + ]) 160 + .withExec([ 161 + "dpkg", 162 + "-x", 163 + "libcap-dev_1%3a2.44-1+deb11u1_armhf.deb", 164 + "/build/sysroot/", 165 + ]) 166 + .withExec([ 167 + "dpkg", 168 + "-x", 169 + "libcap2_1%3a2.44-1+deb11u1_armhf.deb", 170 + "/", 171 + ]) 172 + .withExec([ 173 + "dpkg", 174 + "-x", 175 + "libgcrypt20-dev_1.8.7-6_armhf.deb", 176 + "/build/sysroot/", 177 + ]) 178 + .withExec([ 179 + "dpkg", 180 + "-x", 181 + "libgcrypt20_1.8.7-6_armhf.deb", 182 + "/build/sysroot/", 183 + ]) 184 + .withExec([ 185 + "dpkg", 186 + "-x", 187 + "libgpg-error-dev_1.38-2_armhf.deb", 188 + "/build/sysroot/", 189 + ]) 190 + .withExec([ 191 + "dpkg", 192 + "-x", 193 + "libgpg-error0_1.38-2_armhf.deb", 194 + "/", 195 + ]) 196 + .withExec([ 197 + "dpkg", 198 + "-x", 199 + "liblz4-1_1.9.3-2_armhf.deb", 200 + "/build/sysroot/", 201 + ]) 202 + .withExec([ 203 + "dpkg", 204 + "-x", 205 + "liblz4-dev_1.9.3-2_armhf.deb", 206 + "/build/sysroot/", 207 + ]) 208 + .withExec([ 209 + "dpkg", 210 + "-x", 211 + "liblzma-dev_5.2.5-2.1~deb11u1_armhf.deb", 212 + "/build/sysroot/", 213 + ]) 214 + .withExec([ 215 + "dpkg", 216 + "-x", 217 + "liblzma5_5.2.5-2.1~deb11u1_armhf.deb", 218 + "/", 219 + ]) 220 + .withExec([ 221 + "dpkg", 222 + "-x", 223 + "libxxhash-dev_0.8.0-2_armhf.deb", 224 + "/build/sysroot/", 225 + ]) 226 + .withExec([ 227 + "dpkg", 228 + "-x", 229 + "libxxhash0_0.8.0-2_armhf.deb", 230 + "/build/sysroot/", 231 + ]) 232 + .withExec([ 233 + "dpkg", 234 + "-x", 235 + "libzstd1_1.4.8+dfsg-2.1_armhf.deb", 236 + "/build/sysroot/", 237 + ]) 238 + .withExec([ 239 + "dpkg", 240 + "-x", 241 + "libzstd-dev_1.4.8+dfsg-2.1_armhf.deb", 242 + "/build/sysroot/", 243 + ]) 244 + .withExec([ 245 + "dpkg", 246 + "-x", 247 + "libdbus-1-dev_1.12.28-0+deb11u1_arm64.deb", 248 + "/build/sysroot/", 249 + ]) 250 + .withExec([ 251 + "dpkg", 252 + "-x", 253 + "libdbus-1-3_1.12.28-0+deb11u1_arm64.deb", 254 + "/build/sysroot/", 255 + ]) 256 + .withExec([ 257 + "dpkg", 258 + "-x", 259 + "libsystemd-dev_247.3-7+deb11u7_arm64.deb", 260 + "/build/sysroot/", 261 + ]) 262 + .withExec([ 263 + "dpkg", 264 + "-x", 265 + "libsystemd0_247.3-7+deb11u7_arm64.deb", 266 + "/build/sysroot/", 267 + ]) 268 + .withExec([ 269 + "dpkg", 270 + "-x", 271 + "libcap-dev_1%3a2.44-1+deb11u1_arm64.deb", 272 + "/build/sysroot/", 273 + ]) 274 + .withExec([ 275 + "dpkg", 276 + "-x", 277 + "libcap2_1%3a2.44-1+deb11u1_arm64.deb", 278 + "/", 279 + ]) 280 + .withExec([ 281 + "dpkg", 282 + "-x", 283 + "libgcrypt20-dev_1.8.7-6_arm64.deb", 284 + "/build/sysroot/", 285 + ]) 286 + .withExec([ 287 + "dpkg", 288 + "-x", 289 + "libgcrypt20_1.8.7-6_arm64.deb", 290 + "/build/sysroot/", 291 + ]) 292 + .withExec([ 293 + "dpkg", 294 + "-x", 295 + "libgpg-error-dev_1.38-2_arm64.deb", 296 + "/build/sysroot/", 297 + ]) 298 + .withExec([ 299 + "dpkg", 300 + "-x", 301 + "libgpg-error0_1.38-2_arm64.deb", 302 + "/", 303 + ]) 304 + .withExec([ 305 + "dpkg", 306 + "-x", 307 + "liblz4-1_1.9.3-2_arm64.deb", 308 + "/build/sysroot/", 309 + ]) 310 + .withExec([ 311 + "dpkg", 312 + "-x", 313 + "liblz4-dev_1.9.3-2_arm64.deb", 314 + "/build/sysroot/", 315 + ]) 316 + .withExec([ 317 + "dpkg", 318 + "-x", 319 + "liblzma-dev_5.2.5-2.1~deb11u1_arm64.deb", 320 + "/build/sysroot/", 321 + ]) 322 + .withExec([ 323 + "dpkg", 324 + "-x", 325 + "liblzma5_5.2.5-2.1~deb11u1_arm64.deb", 326 + "/", 327 + ]) 328 + .withExec([ 329 + "dpkg", 330 + "-x", 331 + "libxxhash-dev_0.8.0-2_arm64.deb", 332 + "/build/sysroot/", 333 + ]) 334 + .withExec([ 335 + "dpkg", 336 + "-x", 337 + "libxxhash0_0.8.0-2_arm64.deb", 338 + "/build/sysroot/", 339 + ]) 340 + .withExec([ 341 + "dpkg", 342 + "-x", 343 + "libzstd1_1.4.8+dfsg-2.1_arm64.deb", 344 + "/build/sysroot/", 345 + ]) 346 + .withExec([ 347 + "dpkg", 348 + "-x", 349 + "libzstd-dev_1.4.8+dfsg-2.1_arm64.deb", 350 + "/build/sysroot/", 351 + ]) 352 + .withDirectory("/app", context, { exclude }) 353 + .withDirectory("/app", context, { exclude }) 354 + .withWorkdir("/app") 355 + .withMountedCache("/app/target", dag.cacheVolume("target")) 356 + .withMountedCache("/root/cargo/registry", dag.cacheVolume("registry")) 357 + .withMountedCache("/assets", dag.cacheVolume("gh-release-assets")) 358 + .withEnvVariable("RUSTFLAGS", rustflags) 359 + .withEnvVariable( 360 + "PKG_CONFIG_ALLOW_CROSS", 361 + Deno.env.get("TARGET") !== "x86_64-unknown-linux-gnu" ? "1" : "0", 362 + ) 363 + .withEnvVariable( 364 + "C_INCLUDE_PATH", 365 + Deno.env.get("TARGET") !== "x86_64-unknown-linux-gnu" 366 + ? "/build/sysroot/usr/include" 367 + : "/usr/include", 368 + ) 369 + .withEnvVariable("TAG", Deno.env.get("TAG") || "latest") 370 + .withEnvVariable( 371 + "TARGET", 372 + Deno.env.get("TARGET") || "x86_64-unknown-linux-gnu", 373 + ) 374 + .withExec([ 375 + "sh", 376 + "-c", 377 + "mv /usr/bin/protoc /usr/bin/_protoc && cp tools/protoc /usr/bin/protoc && chmod a+x /usr/bin/protoc", 378 + ]) 379 + .withExec(["sh", "-c", "rustup target add $TARGET"]) 380 + .withExec(["sh", "-c", "cargo build --release --target $TARGET"]) 381 + .withExec(["sh", "-c", "cp target/${TARGET}/release/tunein ."]) 382 + .withExec([ 383 + "sh", 384 + "-c", 385 + "tar czvf /assets/tunein_${TAG}_${TARGET}.tar.gz tunein README.md LICENSE", 386 + ]) 387 + .withExec([ 388 + "sh", 389 + "-c", 390 + "shasum -a 256 /assets/tunein_${TAG}_${TARGET}.tar.gz > /assets/tunein_${TAG}_${TARGET}.tar.gz.sha256", 391 + ]) 392 + .withExec(["sh", "-c", "cp /assets/tunein_${TAG}_${TARGET}.tar.gz ."]) 393 + .withExec([ 394 + "sh", 395 + "-c", 396 + "cp /assets/tunein_${TAG}_${TARGET}.tar.gz.sha256 .", 397 + ]); 398 + 399 + const exe = await ctr.file( 400 + `/app/tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz`, 401 + ); 402 + await exe.export( 403 + `./tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz`, 404 + ); 405 + 406 + const sha = await ctr.file( 407 + `/app/tunein_${Deno.env.get("TAG")}_${ 408 + Deno.env.get("TARGET") 409 + }.tar.gz.sha256`, 410 + ); 411 + await sha.export( 412 + `./tunein_${Deno.env.get("TAG")}_${Deno.env.get("TARGET")}.tar.gz.sha256`, 413 + ); 414 + return ctr.stdout(); 415 + }; 416 + 417 + export type JobExec = (src?: string) => 418 + | Promise<string> 419 + | (( 420 + src?: string, 421 + options?: { 422 + ignore: string[]; 423 + }, 424 + ) => Promise<string>); 425 + 426 + export const runnableJobs: Record<Job, JobExec> = { 427 + [Job.test]: test, 428 + [Job.build]: build, 429 + }; 430 + 431 + export const jobDescriptions: Record<Job, string> = { 432 + [Job.test]: "Run tests", 433 + [Job.build]: "Build the project", 434 + };
+63
.fluentci/src/lib.ts
···
··· 1 + import { dag } from "../sdk/client.gen.ts"; 2 + import { Directory, DirectoryID } from "../deps.ts"; 3 + 4 + export const getDirectory = async ( 5 + src: string | Directory | undefined = ".", 6 + ) => { 7 + if (src instanceof Directory) { 8 + return src; 9 + } 10 + if (typeof src === "string") { 11 + try { 12 + const directory = dag.loadDirectoryFromID(src as DirectoryID); 13 + await directory.id(); 14 + return directory; 15 + } catch (_) { 16 + return dag.host 17 + ? dag.host().directory(src) 18 + : dag.currentModule().source().directory(src); 19 + } 20 + } 21 + return dag.host 22 + ? dag.host().directory(src) 23 + : dag.currentModule().source().directory(src); 24 + }; 25 + 26 + export function buildRustFlags(): string { 27 + let rustflags = ""; 28 + switch (Deno.env.get("TARGET")) { 29 + case "aarch64-unknown-linux-gnu": 30 + rustflags = `-Clink-arg=-lsystemd \ 31 + -Clink-arg=-lcap \ 32 + -Clink-arg=-lgcrypt \ 33 + -Clink-arg=-lgpg-error \ 34 + -Clink-arg=-llz4 \ 35 + -Clink-arg=-llzma \ 36 + -Clink-arg=-lpsx \ 37 + -Clink-arg=-lxxhash \ 38 + -Clink-arg=-lzstd \ 39 + -C linker=aarch64-linux-gnu-gcc \ 40 + -L/usr/aarch64-linux-gnu/lib \ 41 + -L/build/sysroot/usr/lib/aarch64-linux-gnu \ 42 + -L/build/sysroot/lib/aarch64-linux-gnu`; 43 + break; 44 + case "armv7-unknown-linux-gnueabihf": 45 + rustflags = `-Clink-arg=-lsystemd \ 46 + -Clink-arg=-lcap \ 47 + -Clink-arg=-lgcrypt \ 48 + -Clink-arg=-lgpg-error \ 49 + -Clink-arg=-llz4 \ 50 + -Clink-arg=-llzma \ 51 + -Clink-arg=-lpsx \ 52 + -Clink-arg=-lxxhash \ 53 + -Clink-arg=-lzstd \ 54 + -C linker=arm-linux-gnueabihf-gcc \ 55 + -L/usr/arm-linux-gnueabihf/lib \ 56 + -L/build/sysroot/usr/lib/arm-linux-gnueabihf \ 57 + -L/build/sysroot/lib/arm-linux-gnueabihf`; 58 + break; 59 + default: 60 + break; 61 + } 62 + return rustflags; 63 + }
+20
.fluentci/src/list_jobs.ts
···
··· 1 + import { brightGreen, stringifyTree } from "../deps.ts"; 2 + import { Job, jobDescriptions, runnableJobs } from "./jobs.ts"; 3 + 4 + const tree = { 5 + name: brightGreen("rust_pipeline"), 6 + children: (Object.keys(runnableJobs) as Job[]).map((job) => ({ 7 + name: jobDescriptions[job] 8 + ? `${brightGreen(job)} - ${jobDescriptions[job]}` 9 + : brightGreen(job), 10 + children: [], 11 + })), 12 + }; 13 + 14 + console.log( 15 + stringifyTree( 16 + tree, 17 + (t) => t.name, 18 + (t) => t.children, 19 + ), 20 + );
+4
.fluentci/src/mod.ts
···
··· 1 + import pipeline from "./pipeline.ts"; 2 + import { build, test, jobDescriptions } from "./jobs.ts"; 3 + 4 + export { pipeline, build, test, jobDescriptions };
+24
.fluentci/src/pipeline.ts
···
··· 1 + import * as jobs from "./jobs.ts"; 2 + 3 + const { build, test } = jobs; 4 + 5 + export default async function pipeline(src = ".", args: string[] = []) { 6 + if (args.length > 0) { 7 + await runSpecificJobs(args); 8 + return; 9 + } 10 + 11 + await test(src); 12 + await build(src); 13 + } 14 + 15 + async function runSpecificJobs(args: string[]) { 16 + for (const name of args) { 17 + // deno-lint-ignore no-explicit-any 18 + const job = (jobs as any)[name]; 19 + if (!job) { 20 + throw new Error(`Job ${name} not found`); 21 + } 22 + await job(); 23 + } 24 + }
+3
.fluentci/src/runner.ts
···
··· 1 + import pipeline from "./pipeline.ts"; 2 + 3 + await pipeline(".", Deno.args);
+2 -1
.gitignore
··· 1 /target 2 - /result
··· 1 /target 2 + /result 3 + *.md
+52 -2
Cargo.lock
··· 1065 ] 1066 1067 [[package]] 1068 name = "discard" 1069 version = "1.0.4" 1070 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2147 ] 2148 2149 [[package]] 2150 name = "linked-hash-map" 2151 version = "0.5.6" 2152 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2599 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 2600 2601 [[package]] 2602 name = "os_str_bytes" 2603 version = "6.6.1" 2604 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3024 checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" 3025 3026 [[package]] 3027 name = "regex" 3028 version = "1.11.1" 3029 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3963 checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d" 3964 dependencies = [ 3965 "libc", 3966 - "libredox", 3967 "numtoa", 3968 "redox_termios", 3969 ] ··· 4358 4359 [[package]] 4360 name = "tunein-cli" 4361 - version = "0.3.2" 4362 dependencies = [ 4363 "anyhow", 4364 "async-trait", ··· 4366 "cpal", 4367 "crossterm", 4368 "derive_more", 4369 "futures", 4370 "futures-util", 4371 "hyper 0.14.32", ··· 4381 "rodio", 4382 "rustfft", 4383 "serde", 4384 "souvlaki", 4385 "surf", 4386 "symphonia",
··· 1065 ] 1066 1067 [[package]] 1068 + name = "directories" 1069 + version = "5.0.1" 1070 + source = "registry+https://github.com/rust-lang/crates.io-index" 1071 + checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 1072 + dependencies = [ 1073 + "dirs-sys", 1074 + ] 1075 + 1076 + [[package]] 1077 + name = "dirs-sys" 1078 + version = "0.4.1" 1079 + source = "registry+https://github.com/rust-lang/crates.io-index" 1080 + checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 1081 + dependencies = [ 1082 + "libc", 1083 + "option-ext", 1084 + "redox_users", 1085 + "windows-sys 0.48.0", 1086 + ] 1087 + 1088 + [[package]] 1089 name = "discard" 1090 version = "1.0.4" 1091 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2168 ] 2169 2170 [[package]] 2171 + name = "libredox" 2172 + version = "0.1.10" 2173 + source = "registry+https://github.com/rust-lang/crates.io-index" 2174 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 2175 + dependencies = [ 2176 + "bitflags 2.8.0", 2177 + "libc", 2178 + ] 2179 + 2180 + [[package]] 2181 name = "linked-hash-map" 2182 version = "0.5.6" 2183 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2630 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 2631 2632 [[package]] 2633 + name = "option-ext" 2634 + version = "0.2.0" 2635 + source = "registry+https://github.com/rust-lang/crates.io-index" 2636 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 2637 + 2638 + [[package]] 2639 name = "os_str_bytes" 2640 version = "6.6.1" 2641 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3061 checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" 3062 3063 [[package]] 3064 + name = "redox_users" 3065 + version = "0.4.6" 3066 + source = "registry+https://github.com/rust-lang/crates.io-index" 3067 + checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 3068 + dependencies = [ 3069 + "getrandom 0.2.15", 3070 + "libredox 0.1.10", 3071 + "thiserror 1.0.69", 3072 + ] 3073 + 3074 + [[package]] 3075 name = "regex" 3076 version = "1.11.1" 3077 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4011 checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d" 4012 dependencies = [ 4013 "libc", 4014 + "libredox 0.0.2", 4015 "numtoa", 4016 "redox_termios", 4017 ] ··· 4406 4407 [[package]] 4408 name = "tunein-cli" 4409 + version = "0.4.1" 4410 dependencies = [ 4411 "anyhow", 4412 "async-trait", ··· 4414 "cpal", 4415 "crossterm", 4416 "derive_more", 4417 + "directories", 4418 "futures", 4419 "futures-util", 4420 "hyper 0.14.32", ··· 4430 "rodio", 4431 "rustfft", 4432 "serde", 4433 + "serde_json", 4434 "souvlaki", 4435 "surf", 4436 "symphonia",
+4 -2
Cargo.toml
··· 8 name = "tunein-cli" 9 readme = "README.md" 10 repository = "https://github.com/tsirysndr/tunein-cli" 11 - version = "0.3.2" 12 13 [[bin]] 14 name = "tunein" ··· 45 m3u = "1.0.0" 46 minimp3 = "0.6" 47 owo-colors = "3.5.0" 48 pls = "0.2.2" 49 prost = "0.13.2" 50 radiobrowser = { version = "0.6.1", features = [ ··· 58 ], default-features = false } 59 rodio = { version = "0.16" } 60 rustfft = "6.2.0" 61 - serde = "1.0.197" 62 surf = { version = "2.3.2", features = [ 63 "h1-client-rustls", 64 ], default-features = false }
··· 8 name = "tunein-cli" 9 readme = "README.md" 10 repository = "https://github.com/tsirysndr/tunein-cli" 11 + version = "0.4.1" 12 13 [[bin]] 14 name = "tunein" ··· 45 m3u = "1.0.0" 46 minimp3 = "0.6" 47 owo-colors = "3.5.0" 48 + directories = "5.0.1" 49 pls = "0.2.2" 50 prost = "0.13.2" 51 radiobrowser = { version = "0.6.1", features = [ ··· 59 ], default-features = false } 60 rodio = { version = "0.16" } 61 rustfft = "6.2.0" 62 + serde = { version = "1.0.197", features = ["derive"] } 63 + serde_json = "1.0.117" 64 surf = { version = "2.3.2", features = [ 65 "h1-client-rustls", 66 ], default-features = false }
+3 -3
README.md
··· 36 ```bash 37 # Install dependencies 38 brew install protobuf # macOS 39 - sudo apt-get install -y libasound2-dev protobuf-compiler # Ubuntu/Debian 40 # Compile and install 41 git clone https://github.com/tsirysndr/tunein-cli 42 cd tunein-cli ··· 108 Or download the latest release for your platform [here](https://github.com/tsirysndr/tunein-cli/releases). 109 110 ## ๐Ÿ“ฆ Downloads 111 - - `Mac`: arm64: [tunein_v0.3.2_aarch64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.3.2/tunein_v0.3.2_aarch64-apple-darwin.tar.gz) intel: [tunein_v0.3.2_x86_64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.3.2/tunein_v0.3.2_x86_64-apple-darwin.tar.gz) 112 - - `Linux`: [tunein_v0.3.2_x86_64-unknown-linux-gnu.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.3.2/tunein_v0.3.2_x86_64-unknown-linux-gnu.tar.gz) 113 114 ## ๐Ÿš€ Usage 115 ```
··· 36 ```bash 37 # Install dependencies 38 brew install protobuf # macOS 39 + sudo apt-get install -y libasound2-dev protobuf-compiler libdbus-1-dev # Ubuntu/Debian 40 # Compile and install 41 git clone https://github.com/tsirysndr/tunein-cli 42 cd tunein-cli ··· 108 Or download the latest release for your platform [here](https://github.com/tsirysndr/tunein-cli/releases). 109 110 ## ๐Ÿ“ฆ Downloads 111 + - `Mac`: arm64: [tunein_v0.4.1_aarch64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.4.1/tunein_v0.4.1_aarch64-apple-darwin.tar.gz) intel: [tunein_v0.4.1_x86_64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.4.1/tunein_v0.4.1_x86_64-apple-darwin.tar.gz) 112 + - `Linux`: [tunein_v0.4.1_x86_64-unknown-linux-gnu.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.4.1/tunein_v0.4.1_x86_64-unknown-linux-gnu.tar.gz) 113 114 ## ๐Ÿš€ Usage 115 ```
+1
build.rs
··· 2 tonic_build::configure() 3 .out_dir("src/api") 4 .file_descriptor_set_path("src/api/descriptor.bin") 5 .compile_protos( 6 &[ 7 "proto/objects/v1alpha1/category.proto",
··· 2 tonic_build::configure() 3 .out_dir("src/api") 4 .file_descriptor_set_path("src/api/descriptor.bin") 5 + .protoc_arg("--experimental_allow_proto3_optional") 6 .compile_protos( 7 &[ 8 "proto/objects/v1alpha1/category.proto",
+2 -2
dist/debian/amd64/DEBIAN/control
··· 1 Package: tunein-cli 2 - Version: 0.3.2 3 Section: user/multimedia 4 Priority: optional 5 Architecture: amd64 6 Maintainer: Tsiry Sandratraina <tsiry.sndr@fluentci.io> 7 - Depends: alsa-utils, libasound2-dev 8 Description: Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
··· 1 Package: tunein-cli 2 + Version: 0.4.1 3 Section: user/multimedia 4 Priority: optional 5 Architecture: amd64 6 Maintainer: Tsiry Sandratraina <tsiry.sndr@fluentci.io> 7 + Depends: alsa-utils, libasound2-dev, libdbus-1-3 8 Description: Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
+2 -2
dist/debian/arm64/DEBIAN/control
··· 1 Package: tunein-cli 2 - Version: 0.3.2 3 Section: user/multimedia 4 Priority: optional 5 Architecture: arm64 6 Maintainer: Tsiry Sandratraina <tsiry.sndr@fluentci.io> 7 - Depends: alsa-utils, libasound2-dev 8 Description: Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ 9
··· 1 Package: tunein-cli 2 + Version: 0.4.1 3 Section: user/multimedia 4 Priority: optional 5 Architecture: arm64 6 Maintainer: Tsiry Sandratraina <tsiry.sndr@fluentci.io> 7 + Depends: alsa-utils, libasound2-dev, libdbus-1-3 8 Description: Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ 9
+2 -2
dist/rpm/amd64/tunein.spec
··· 1 Name: tunein-cli 2 - Version: 0.3.2 3 Release: 1%{?dist} 4 Summary: CLI for listening to internet radio stations 5 ··· 7 8 BuildArch: x86_64 9 10 - Requires: alsa-utils, alsa-lib-devel 11 12 %description 13 Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
··· 1 Name: tunein-cli 2 + Version: 0.4.1 3 Release: 1%{?dist} 4 Summary: CLI for listening to internet radio stations 5 ··· 7 8 BuildArch: x86_64 9 10 + Requires: alsa-utils, alsa-lib-devel, dbus-libs 11 12 %description 13 Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
+2 -2
dist/rpm/arm64/tunein.spec
··· 1 2 Name: tunein-cli 3 - Version: 0.3.2 4 Release: 1%{?dist} 5 Summary: CLI for listening to internet radio stations 6 ··· 8 9 BuildArch: aarch64 10 11 - Requires: alsa-utils, alsa-lib-devel 12 13 %description 14 Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
··· 1 2 Name: tunein-cli 3 + Version: 0.4.1 4 Release: 1%{?dist} 5 Summary: CLI for listening to internet radio stations 6 ··· 8 9 BuildArch: aarch64 10 11 + Requires: alsa-utils, alsa-lib-devel, dbus-libs 12 13 %description 14 Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
+16 -98
flake.lock
··· 3 "advisory-db": { 4 "flake": false, 5 "locked": { 6 - "lastModified": 1688041319, 7 - "narHash": "sha256-J4lJWSRTOvXDS/Tckj+/5RvAnPCK+qQUMNZhsojR1SM=", 8 "owner": "rustsec", 9 "repo": "advisory-db", 10 - "rev": "1f538e6f3b8ad37e89b1386e06be080fbe474b3c", 11 "type": "github" 12 }, 13 "original": { ··· 17 } 18 }, 19 "crane": { 20 - "inputs": { 21 - "flake-compat": "flake-compat", 22 - "flake-utils": "flake-utils", 23 - "nixpkgs": [ 24 - "nixpkgs" 25 - ], 26 - "rust-overlay": "rust-overlay" 27 - }, 28 "locked": { 29 - "lastModified": 1688425221, 30 - "narHash": "sha256-DhZnju72DuX9GhOnCOBIE94aCGKC2BOaF+kGxbnP/K0=", 31 "owner": "ipetkov", 32 "repo": "crane", 33 - "rev": "fc6a236548b31aef0be3b0a0377c4459bb39d923", 34 "type": "github" 35 }, 36 "original": { ··· 47 "rust-analyzer-src": [] 48 }, 49 "locked": { 50 - "lastModified": 1688451945, 51 - "narHash": "sha256-87ecJNdUQye1/gV5i8ptqrfYCcO1r0jxDGtfJVGFZ7s=", 52 "owner": "nix-community", 53 "repo": "fenix", 54 - "rev": "6fabc883d4b00eb05af2fe630f18b5ffcfe7f811", 55 "type": "github" 56 }, 57 "original": { ··· 60 "type": "github" 61 } 62 }, 63 - "flake-compat": { 64 - "flake": false, 65 - "locked": { 66 - "lastModified": 1673956053, 67 - "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", 68 - "owner": "edolstra", 69 - "repo": "flake-compat", 70 - "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", 71 - "type": "github" 72 - }, 73 - "original": { 74 - "owner": "edolstra", 75 - "repo": "flake-compat", 76 - "type": "github" 77 - } 78 - }, 79 "flake-utils": { 80 "inputs": { 81 "systems": "systems" 82 }, 83 "locked": { 84 - "lastModified": 1687709756, 85 - "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", 86 - "owner": "numtide", 87 - "repo": "flake-utils", 88 - "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", 89 - "type": "github" 90 - }, 91 - "original": { 92 - "owner": "numtide", 93 - "repo": "flake-utils", 94 - "type": "github" 95 - } 96 - }, 97 - "flake-utils_2": { 98 - "inputs": { 99 - "systems": "systems_2" 100 - }, 101 - "locked": { 102 - "lastModified": 1687709756, 103 - "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", 104 "owner": "numtide", 105 "repo": "flake-utils", 106 - "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", 107 "type": "github" 108 }, 109 "original": { ··· 114 }, 115 "nixpkgs": { 116 "locked": { 117 - "lastModified": 1725432240, 118 - "narHash": "sha256-+yj+xgsfZaErbfYM3T+QvEE2hU7UuE+Jf0fJCJ8uPS0=", 119 "owner": "NixOS", 120 "repo": "nixpkgs", 121 - "rev": "ad416d066ca1222956472ab7d0555a6946746a80", 122 "type": "github" 123 }, 124 "original": { ··· 133 "advisory-db": "advisory-db", 134 "crane": "crane", 135 "fenix": "fenix", 136 - "flake-utils": "flake-utils_2", 137 "nixpkgs": "nixpkgs" 138 } 139 }, 140 - "rust-overlay": { 141 - "inputs": { 142 - "flake-utils": [ 143 - "crane", 144 - "flake-utils" 145 - ], 146 - "nixpkgs": [ 147 - "crane", 148 - "nixpkgs" 149 - ] 150 - }, 151 - "locked": { 152 - "lastModified": 1688351637, 153 - "narHash": "sha256-CLTufJ29VxNOIZ8UTg0lepsn3X03AmopmaLTTeHDCL4=", 154 - "owner": "oxalica", 155 - "repo": "rust-overlay", 156 - "rev": "f9b92316727af9e6c7fee4a761242f7f46880329", 157 - "type": "github" 158 - }, 159 - "original": { 160 - "owner": "oxalica", 161 - "repo": "rust-overlay", 162 - "type": "github" 163 - } 164 - }, 165 "systems": { 166 - "locked": { 167 - "lastModified": 1681028828, 168 - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 169 - "owner": "nix-systems", 170 - "repo": "default", 171 - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 172 - "type": "github" 173 - }, 174 - "original": { 175 - "owner": "nix-systems", 176 - "repo": "default", 177 - "type": "github" 178 - } 179 - }, 180 - "systems_2": { 181 "locked": { 182 "lastModified": 1681028828, 183 "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
··· 3 "advisory-db": { 4 "flake": false, 5 "locked": { 6 + "lastModified": 1756294590, 7 + "narHash": "sha256-CyhicqYGMUCtBAbsyMKIuQVYl5D7m+yh/E0wonouF+A=", 8 "owner": "rustsec", 9 "repo": "advisory-db", 10 + "rev": "11793a852bab94279ef3efc26d69face55a9e2ba", 11 "type": "github" 12 }, 13 "original": { ··· 17 } 18 }, 19 "crane": { 20 "locked": { 21 + "lastModified": 1755993354, 22 + "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", 23 "owner": "ipetkov", 24 "repo": "crane", 25 + "rev": "25bd41b24426c7734278c2ff02e53258851db914", 26 "type": "github" 27 }, 28 "original": { ··· 39 "rust-analyzer-src": [] 40 }, 41 "locked": { 42 + "lastModified": 1755585599, 43 + "narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=", 44 "owner": "nix-community", 45 "repo": "fenix", 46 + "rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42", 47 "type": "github" 48 }, 49 "original": { ··· 52 "type": "github" 53 } 54 }, 55 "flake-utils": { 56 "inputs": { 57 "systems": "systems" 58 }, 59 "locked": { 60 + "lastModified": 1731533236, 61 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 62 "owner": "numtide", 63 "repo": "flake-utils", 64 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 65 "type": "github" 66 }, 67 "original": { ··· 72 }, 73 "nixpkgs": { 74 "locked": { 75 + "lastModified": 1756266583, 76 + "narHash": "sha256-cr748nSmpfvnhqSXPiCfUPxRz2FJnvf/RjJGvFfaCsM=", 77 "owner": "NixOS", 78 "repo": "nixpkgs", 79 + "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", 80 "type": "github" 81 }, 82 "original": { ··· 91 "advisory-db": "advisory-db", 92 "crane": "crane", 93 "fenix": "fenix", 94 + "flake-utils": "flake-utils", 95 "nixpkgs": "nixpkgs" 96 } 97 }, 98 "systems": { 99 "locked": { 100 "lastModified": 1681028828, 101 "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+4 -3
flake.nix
··· 32 33 inherit (pkgs) lib; 34 35 - craneLib = crane.lib.${system}; 36 37 protoFilter = path: _type: builtins.match ".*proto$" path != null; 38 protoOrCargo = path: type: ··· 46 # Common arguments can be set here to avoid repeating them later 47 commonArgs = { 48 inherit src; 49 - 50 pname = "tunein"; 51 - version = "0.3.1"; 52 53 buildInputs = [ 54 # Add additional build inputs here ··· 59 pkgs.perl 60 pkgs.protobuf 61 pkgs.alsa-lib.dev 62 ] ++ lib.optionals pkgs.stdenv.isDarwin [ 63 # Additional darwin specific inputs can be set here 64 pkgs.libiconv
··· 32 33 inherit (pkgs) lib; 34 35 + craneLib = crane.mkLib pkgs; 36 37 protoFilter = path: _type: builtins.match ".*proto$" path != null; 38 protoOrCargo = path: type: ··· 46 # Common arguments can be set here to avoid repeating them later 47 commonArgs = { 48 inherit src; 49 + 50 pname = "tunein"; 51 + version = "0.4.1"; 52 53 buildInputs = [ 54 # Add additional build inputs here ··· 59 pkgs.perl 60 pkgs.protobuf 61 pkgs.alsa-lib.dev 62 + pkgs.dbus 63 ] ++ lib.optionals pkgs.stdenv.isDarwin [ 64 # Additional darwin specific inputs can be set here 65 pkgs.libiconv
+46 -17
src/app.rs
··· 338 ) { 339 let new_state = cmd_rx.recv().await.unwrap(); 340 341 - // report metadata to OS 342 send_os_media_controls_command( 343 self.os_media_controls.as_mut(), 344 os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata { 345 - title: (!new_state.now_playing.is_empty()).then_some(&new_state.now_playing), 346 - album: (!new_state.name.is_empty()).then_some(&new_state.name), 347 artist: None, 348 cover_url: None, 349 duration: None, 350 }), 351 ); 352 - // report started playing to OS 353 send_os_media_controls_command( 354 self.os_media_controls.as_mut(), 355 os_media_controls::Command::Play, 356 ); 357 - // report volume to OS 358 send_os_media_controls_command( 359 self.os_media_controls.as_mut(), 360 os_media_controls::Command::SetVolume(new_state.volume.volume_ratio() as f64), 361 ); 362 363 let new_state = Arc::new(Mutex::new(new_state)); 364 - 365 let id = id.to_string(); 366 let new_state_clone = new_state.clone(); 367 368 - thread::spawn(move || loop { 369 let rt = tokio::runtime::Runtime::new().unwrap(); 370 rt.block_on(async { 371 - let mut new_state = new_state_clone.lock().unwrap(); 372 - // Get current playing if available, otherwise use state's value 373 - new_state.now_playing = get_currently_playing(&id).await.unwrap_or_default(); 374 - drop(new_state); 375 - std::thread::sleep(Duration::from_millis(10000)); 376 }); 377 }); 378 379 let mut fps = 0; 380 let mut framerate = 0; 381 let mut last_poll = Instant::now(); 382 383 loop { 384 let channels = if self.graph.pause { 385 None 386 } else { 387 let Ok(audio_frame) = self.frame_rx.recv() else { 388 - // other thread has closed so application has 389 - // closed 390 return; 391 }; 392 Some(stream_to_matrix( ··· 441 size.y += 8; 442 } 443 let chart = Chart::new(datasets.iter().map(|x| x.into()).collect()) 444 - .x_axis(current_display.axis(&self.graph, Dimension::X)) // TODO allow to have axis sometimes? 445 .y_axis(current_display.axis(&self.graph, Dimension::Y)); 446 f.render_widget(chart, size) 447 } 448 }) 449 .unwrap(); 450 } 451 452 while let Some(event) = self ··· 481 } 482 } 483 } 484 - 485 fn current_display_mut(&mut self) -> Option<&mut dyn DisplayMode> { 486 match self.mode { 487 CurrentDisplayMode::Oscilloscope => { ··· 858 } 859 860 /// Send [`os_media_controls::Command`]. 861 - fn send_os_media_controls_command( 862 os_media_controls: Option<&mut OsMediaControls>, 863 command: os_media_controls::Command<'_>, 864 ) {
··· 338 ) { 339 let new_state = cmd_rx.recv().await.unwrap(); 340 341 + let now_playing = new_state.now_playing.clone(); 342 + let name = new_state.name.clone(); 343 + // Report initial metadata to OS 344 send_os_media_controls_command( 345 self.os_media_controls.as_mut(), 346 os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata { 347 + title: (!now_playing.is_empty()).then(|| now_playing.as_str()), 348 + album: (!name.is_empty()).then(|| name.as_str()), 349 artist: None, 350 cover_url: None, 351 duration: None, 352 }), 353 ); 354 + // Report started playing to OS 355 send_os_media_controls_command( 356 self.os_media_controls.as_mut(), 357 os_media_controls::Command::Play, 358 ); 359 + // Report volume to OS 360 send_os_media_controls_command( 361 self.os_media_controls.as_mut(), 362 os_media_controls::Command::SetVolume(new_state.volume.volume_ratio() as f64), 363 ); 364 365 let new_state = Arc::new(Mutex::new(new_state)); 366 let id = id.to_string(); 367 let new_state_clone = new_state.clone(); 368 369 + // Background thread to update now_playing 370 + thread::spawn(move || { 371 let rt = tokio::runtime::Runtime::new().unwrap(); 372 rt.block_on(async { 373 + loop { 374 + let mut new_state = new_state_clone.lock().unwrap(); 375 + // Get current playing if available, otherwise use default 376 + let now_playing = get_currently_playing(&id).await.unwrap_or_default(); 377 + if new_state.now_playing != now_playing { 378 + new_state.now_playing = now_playing; 379 + } 380 + drop(new_state); 381 + std::thread::sleep(Duration::from_millis(10000)); 382 + } 383 }); 384 }); 385 386 let mut fps = 0; 387 let mut framerate = 0; 388 let mut last_poll = Instant::now(); 389 + let mut last_metadata_update = Instant::now(); 390 + let mut last_now_playing = String::new(); 391 + const METADATA_UPDATE_INTERVAL: Duration = Duration::from_secs(1); // Check every second 392 393 loop { 394 let channels = if self.graph.pause { 395 None 396 } else { 397 let Ok(audio_frame) = self.frame_rx.recv() else { 398 + // other thread has closed so application has closed 399 return; 400 }; 401 Some(stream_to_matrix( ··· 450 size.y += 8; 451 } 452 let chart = Chart::new(datasets.iter().map(|x| x.into()).collect()) 453 + .x_axis(current_display.axis(&self.graph, Dimension::X)) 454 .y_axis(current_display.axis(&self.graph, Dimension::Y)); 455 f.render_widget(chart, size) 456 } 457 }) 458 .unwrap(); 459 + 460 + // Update metadata only if needed and at a controlled interval 461 + if last_metadata_update.elapsed() >= METADATA_UPDATE_INTERVAL { 462 + let state = new_state.lock().unwrap(); 463 + if state.now_playing != last_now_playing { 464 + let now_playing = state.now_playing.clone(); 465 + let name = state.name.clone(); 466 + send_os_media_controls_command( 467 + self.os_media_controls.as_mut(), 468 + os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata { 469 + title: (!now_playing.is_empty()).then_some(now_playing.as_str()), 470 + album: (!name.is_empty()).then_some(name.as_str()), 471 + artist: None, 472 + cover_url: None, 473 + duration: None, 474 + }), 475 + ); 476 + last_now_playing = state.now_playing.clone(); 477 + } 478 + last_metadata_update = Instant::now(); 479 + } 480 } 481 482 while let Some(event) = self ··· 511 } 512 } 513 } 514 fn current_display_mut(&mut self) -> Option<&mut dyn DisplayMode> { 515 match self.mode { 516 CurrentDisplayMode::Oscilloscope => { ··· 887 } 888 889 /// Send [`os_media_controls::Command`]. 890 + pub fn send_os_media_controls_command( 891 os_media_controls: Option<&mut OsMediaControls>, 892 command: os_media_controls::Command<'_>, 893 ) {
+223
src/audio.rs
···
··· 1 + use std::sync::Arc; 2 + use std::thread; 3 + use std::time::Duration; 4 + 5 + use anyhow::{Context, Error}; 6 + use hyper::header::HeaderValue; 7 + use rodio::{OutputStream, OutputStreamHandle, Sink}; 8 + use tokio::sync::mpsc; 9 + 10 + use crate::decoder::Mp3Decoder; 11 + use crate::types::Station; 12 + 13 + /// Commands sent to the audio worker thread. 14 + #[derive(Debug)] 15 + enum AudioCommand { 16 + Play { 17 + station: Station, 18 + volume_percent: f32, 19 + }, 20 + SetVolume(f32), 21 + Stop, 22 + } 23 + 24 + /// Playback events emitted by the audio worker. 25 + #[derive(Debug, Clone)] 26 + pub enum PlaybackEvent { 27 + Started(PlaybackState), 28 + Error(String), 29 + Stopped, 30 + } 31 + 32 + /// Public interface for receiving playback events. 33 + pub struct PlaybackEvents { 34 + rx: mpsc::UnboundedReceiver<PlaybackEvent>, 35 + } 36 + 37 + impl PlaybackEvents { 38 + pub async fn recv(&mut self) -> Option<PlaybackEvent> { 39 + self.rx.recv().await 40 + } 41 + } 42 + 43 + /// Snapshot of the current playback metadata. 44 + #[derive(Debug, Clone)] 45 + pub struct PlaybackState { 46 + pub station: Station, 47 + pub stream_name: String, 48 + pub now_playing: String, 49 + pub genre: String, 50 + pub description: String, 51 + pub bitrate: String, 52 + } 53 + 54 + /// Controller that owns the command channel to the audio worker. 55 + pub struct AudioController { 56 + cmd_tx: mpsc::UnboundedSender<AudioCommand>, 57 + } 58 + 59 + impl AudioController { 60 + /// Spawn a new audio worker thread and return a controller plus event receiver. 61 + pub fn new() -> Result<(Self, PlaybackEvents), Error> { 62 + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<AudioCommand>(); 63 + let (event_tx, event_rx) = mpsc::unbounded_channel::<PlaybackEvent>(); 64 + 65 + thread::Builder::new() 66 + .name("tunein-audio-worker".into()) 67 + .spawn({ 68 + let events = event_tx.clone(); 69 + move || { 70 + let mut worker = AudioWorker::new(event_tx); 71 + if let Err(err) = worker.run(&mut cmd_rx) { 72 + let _ = events.send(PlaybackEvent::Error(err.to_string())); 73 + } 74 + } 75 + }) 76 + .context("failed to spawn audio worker thread")?; 77 + 78 + Ok((Self { cmd_tx }, PlaybackEvents { rx: event_rx })) 79 + } 80 + 81 + pub fn play(&self, station: Station, volume_percent: f32) -> Result<(), Error> { 82 + self.cmd_tx 83 + .send(AudioCommand::Play { 84 + station, 85 + volume_percent, 86 + }) 87 + .map_err(|e| Error::msg(e.to_string())) 88 + } 89 + 90 + pub fn set_volume(&self, volume_percent: f32) -> Result<(), Error> { 91 + self.cmd_tx 92 + .send(AudioCommand::SetVolume(volume_percent)) 93 + .map_err(|e| Error::msg(e.to_string())) 94 + } 95 + 96 + pub fn stop(&self) -> Result<(), Error> { 97 + self.cmd_tx 98 + .send(AudioCommand::Stop) 99 + .map_err(|e| Error::msg(e.to_string())) 100 + } 101 + } 102 + 103 + struct AudioWorker { 104 + _stream: OutputStream, 105 + handle: OutputStreamHandle, 106 + sink: Option<Arc<Sink>>, 107 + current_volume: f32, 108 + events: mpsc::UnboundedSender<PlaybackEvent>, 109 + } 110 + 111 + impl AudioWorker { 112 + fn new(events: mpsc::UnboundedSender<PlaybackEvent>) -> Self { 113 + let (stream, handle) = 114 + OutputStream::try_default().expect("failed to acquire default audio output device"); 115 + Self { 116 + _stream: stream, 117 + handle, 118 + sink: None, 119 + current_volume: 100.0, 120 + events, 121 + } 122 + } 123 + 124 + fn run(&mut self, cmd_rx: &mut mpsc::UnboundedReceiver<AudioCommand>) -> Result<(), Error> { 125 + while let Some(cmd) = cmd_rx.blocking_recv() { 126 + match cmd { 127 + AudioCommand::Play { 128 + station, 129 + volume_percent, 130 + } => self.handle_play(station, volume_percent)?, 131 + AudioCommand::SetVolume(volume_percent) => { 132 + self.current_volume = volume_percent.max(0.0); 133 + if let Some(sink) = &self.sink { 134 + sink.set_volume(self.current_volume / 100.0); 135 + } 136 + } 137 + AudioCommand::Stop => { 138 + if let Some(sink) = self.sink.take() { 139 + sink.stop(); 140 + } 141 + let _ = self.events.send(PlaybackEvent::Stopped); 142 + } 143 + } 144 + } 145 + 146 + Ok(()) 147 + } 148 + 149 + fn handle_play(&mut self, station: Station, volume_percent: f32) -> Result<(), Error> { 150 + if let Some(sink) = self.sink.take() { 151 + sink.stop(); 152 + thread::sleep(Duration::from_millis(50)); 153 + } 154 + 155 + let stream_url = station.stream_url.clone(); 156 + let client = reqwest::blocking::Client::new(); 157 + let response = client 158 + .get(&stream_url) 159 + .send() 160 + .with_context(|| format!("failed to open stream {}", stream_url))?; 161 + 162 + let headers = response.headers().clone(); 163 + let now_playing = station.playing.clone().unwrap_or_default(); 164 + 165 + let display_name = header_to_string(headers.get("icy-name")) 166 + .filter(|name| name != "Unknown") 167 + .unwrap_or_else(|| station.name.clone()); 168 + let genre = header_to_string(headers.get("icy-genre")).unwrap_or_default(); 169 + let description = header_to_string(headers.get("icy-description")).unwrap_or_default(); 170 + let bitrate = header_to_string(headers.get("icy-br")).unwrap_or_default(); 171 + 172 + let response = follow_redirects(client, response)?; 173 + 174 + let sink = Arc::new(Sink::try_new(&self.handle)?); 175 + sink.set_volume(volume_percent.max(0.0) / 100.0); 176 + 177 + let decoder = Mp3Decoder::new(response, None).map_err(|_| { 178 + Error::msg("stream is not in MP3 format or failed to initialize decoder") 179 + })?; 180 + sink.append(decoder); 181 + sink.play(); 182 + 183 + self.current_volume = volume_percent; 184 + self.sink = Some(sink.clone()); 185 + 186 + let state = PlaybackState { 187 + station, 188 + stream_name: display_name, 189 + now_playing, 190 + genre, 191 + description, 192 + bitrate, 193 + }; 194 + 195 + let _ = self.events.send(PlaybackEvent::Started(state)); 196 + 197 + Ok(()) 198 + } 199 + } 200 + 201 + fn follow_redirects( 202 + client: reqwest::blocking::Client, 203 + response: reqwest::blocking::Response, 204 + ) -> Result<reqwest::blocking::Response, Error> { 205 + let mut current = response; 206 + for _ in 0..3 { 207 + if let Some(location) = current.headers().get("location") { 208 + let url = location 209 + .to_str() 210 + .map_err(|_| Error::msg("invalid redirect location header"))?; 211 + current = client.get(url).send()?; 212 + } else { 213 + return Ok(current); 214 + } 215 + } 216 + Ok(current) 217 + } 218 + 219 + fn header_to_string(value: Option<&HeaderValue>) -> Option<String> { 220 + value 221 + .and_then(|header| header.to_str().ok()) 222 + .map(|s| s.to_string()) 223 + }
+6 -5
src/decoder.rs
··· 11 decoder: Decoder<R>, 12 current_frame: Frame, 13 current_frame_offset: usize, 14 - tx: Sender<Frame>, 15 } 16 17 impl<R> Mp3Decoder<R> 18 where 19 R: Read, 20 { 21 - pub fn new(mut data: R, tx: Sender<Frame>) -> Result<Self, R> { 22 if !is_mp3(data.by_ref()) { 23 return Err(data); 24 } ··· 70 if self.current_frame_offset == self.current_frame.data.len() { 71 match self.decoder.next_frame() { 72 Ok(frame) => { 73 - match self.tx.send(frame.clone()) { 74 - Ok(_) => {} 75 - Err(_) => return None, 76 } 77 self.current_frame = frame 78 }
··· 11 decoder: Decoder<R>, 12 current_frame: Frame, 13 current_frame_offset: usize, 14 + tx: Option<Sender<Frame>>, 15 } 16 17 impl<R> Mp3Decoder<R> 18 where 19 R: Read, 20 { 21 + pub fn new(mut data: R, tx: Option<Sender<Frame>>) -> Result<Self, R> { 22 if !is_mp3(data.by_ref()) { 23 return Err(data); 24 } ··· 70 if self.current_frame_offset == self.current_frame.data.len() { 71 match self.decoder.next_frame() { 72 Ok(frame) => { 73 + if let Some(tx) = &self.tx { 74 + if tx.send(frame.clone()).is_err() { 75 + return None; 76 + } 77 } 78 self.current_frame = frame 79 }
+6 -1
src/extract.rs
··· 65 .await 66 .map_err(|e| Error::msg(e.to_string()))?; 67 68 - Ok(response.header.subtitle) 69 }
··· 65 .await 66 .map_err(|e| Error::msg(e.to_string()))?; 67 68 + let subtitle = response.header.subtitle.trim(); 69 + if subtitle.is_empty() { 70 + Ok(response.header.title.trim().to_string()) 71 + } else { 72 + Ok(subtitle.to_string()) 73 + } 74 }
+108
src/favorites.rs
···
··· 1 + use std::fs; 2 + use std::path::{Path, PathBuf}; 3 + 4 + use anyhow::{Context, Error}; 5 + use directories::ProjectDirs; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + /// Metadata describing a favourited station. 9 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 10 + pub struct FavoriteStation { 11 + pub id: String, 12 + pub name: String, 13 + pub provider: String, 14 + } 15 + 16 + /// File-backed favourites store. 17 + pub struct FavoritesStore { 18 + path: PathBuf, 19 + favorites: Vec<FavoriteStation>, 20 + } 21 + 22 + impl FavoritesStore { 23 + /// Load favourites from disk, falling back to an empty list when the file 24 + /// does not exist or is corrupted. 25 + pub fn load() -> Result<Self, Error> { 26 + let path = favorites_path()?; 27 + ensure_parent(&path)?; 28 + 29 + let favorites = match fs::read_to_string(&path) { 30 + Ok(content) => match serde_json::from_str::<Vec<FavoriteStation>>(&content) { 31 + Ok(entries) => entries, 32 + Err(err) => { 33 + eprintln!( 34 + "warning: favourites file corrupted ({}), starting fresh", 35 + err 36 + ); 37 + Vec::new() 38 + } 39 + }, 40 + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(), 41 + Err(err) => return Err(Error::from(err).context("failed to read favourites file")), 42 + }; 43 + 44 + Ok(Self { path, favorites }) 45 + } 46 + 47 + /// Return a snapshot of all favourite stations. 48 + pub fn all(&self) -> &[FavoriteStation] { 49 + &self.favorites 50 + } 51 + 52 + /// Check whether the provided station is already a favourite. 53 + pub fn is_favorite(&self, id: &str, provider: &str) -> bool { 54 + self.favorites 55 + .iter() 56 + .any(|fav| fav.id == id && fav.provider == provider) 57 + } 58 + 59 + /// Add a station to favourites if it is not already present. 60 + pub fn add(&mut self, favorite: FavoriteStation) -> Result<(), Error> { 61 + if !self.is_favorite(&favorite.id, &favorite.provider) { 62 + self.favorites.push(favorite); 63 + self.save()?; 64 + } 65 + Ok(()) 66 + } 67 + 68 + /// Remove a station from favourites. 69 + pub fn remove(&mut self, id: &str, provider: &str) -> Result<(), Error> { 70 + let initial_len = self.favorites.len(); 71 + self.favorites 72 + .retain(|fav| !(fav.id == id && fav.provider == provider)); 73 + if self.favorites.len() != initial_len { 74 + self.save()?; 75 + } 76 + Ok(()) 77 + } 78 + 79 + /// Toggle a station in favourites, returning whether it was added (`true`) or removed (`false`). 80 + pub fn toggle(&mut self, favorite: FavoriteStation) -> Result<bool, Error> { 81 + if self.is_favorite(&favorite.id, &favorite.provider) { 82 + self.remove(&favorite.id, &favorite.provider)?; 83 + Ok(false) 84 + } else { 85 + self.add(favorite)?; 86 + Ok(true) 87 + } 88 + } 89 + 90 + fn save(&self) -> Result<(), Error> { 91 + let serialized = serde_json::to_string_pretty(&self.favorites) 92 + .context("failed to serialize favourites list")?; 93 + fs::write(&self.path, serialized).context("failed to write favourites file") 94 + } 95 + } 96 + 97 + fn favorites_path() -> Result<PathBuf, Error> { 98 + let dirs = ProjectDirs::from("io", "tunein-cli", "tunein-cli") 99 + .ok_or_else(|| Error::msg("unable to determine configuration directory"))?; 100 + Ok(dirs.config_dir().join("favorites.json")) 101 + } 102 + 103 + fn ensure_parent(path: &Path) -> Result<(), Error> { 104 + if let Some(parent) = path.parent() { 105 + fs::create_dir_all(parent).context("failed to create favourites directory")?; 106 + } 107 + Ok(()) 108 + }
+1263
src/interactive.rs
···
··· 1 + use std::thread; 2 + use std::time::{Duration, Instant}; 3 + 4 + use anyhow::{anyhow, Error}; 5 + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; 6 + use ratatui::layout::{Constraint, Direction, Layout}; 7 + use ratatui::prelude::*; 8 + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; 9 + use tokio::sync::mpsc; 10 + use tunein_cli::os_media_controls::{self, OsMediaControls}; 11 + 12 + use crate::app::send_os_media_controls_command; 13 + use crate::audio::{AudioController, PlaybackEvent, PlaybackState}; 14 + use crate::extract::get_currently_playing; 15 + use crate::favorites::{FavoriteStation, FavoritesStore}; 16 + use crate::provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}; 17 + use crate::tui; 18 + use crate::types::Station; 19 + 20 + const MENU_OPTIONS: &[&str] = &[ 21 + "Search Stations", 22 + "Browse Categories", 23 + "Play Station", 24 + "Favourites", 25 + "Resume Last Station", 26 + "Quit", 27 + ]; 28 + 29 + const STATUS_TIMEOUT: Duration = Duration::from_secs(3); 30 + const NOW_PLAYING_POLL_INTERVAL: Duration = Duration::from_secs(10); 31 + 32 + enum HubMessage { 33 + NowPlaying(String), 34 + } 35 + 36 + pub async fn run(provider_name: &str) -> Result<(), Error> { 37 + let provider = resolve_provider(provider_name).await?; 38 + let (audio, mut audio_events) = AudioController::new()?; 39 + let favorites = FavoritesStore::load()?; 40 + let (metadata_tx, mut metadata_rx) = mpsc::unbounded_channel::<HubMessage>(); 41 + 42 + let mut terminal = tui::init()?; 43 + 44 + let (input_tx, mut input_rx) = mpsc::unbounded_channel(); 45 + spawn_input_thread(input_tx.clone()); 46 + 47 + let os_media_controls = OsMediaControls::new() 48 + .inspect_err(|err| { 49 + eprintln!( 50 + "error: failed to initialize os media controls due to `{}`", 51 + err 52 + ); 53 + }) 54 + .ok(); 55 + 56 + let mut app = HubApp::new( 57 + provider_name.to_string(), 58 + provider, 59 + audio, 60 + favorites, 61 + metadata_tx, 62 + os_media_controls, 63 + ); 64 + 65 + let result = loop { 66 + terminal.draw(|frame| app.render(frame))?; 67 + 68 + tokio::select! { 69 + Some(event) = input_rx.recv() => { 70 + match app.handle_event(event).await? { 71 + Action::Quit => break Ok(()), 72 + Action::Task(task) => app.perform_task(task).await?, 73 + Action::None => {} 74 + } 75 + } 76 + Some(event) = audio_events.recv() => { 77 + app.handle_playback_event(event); 78 + } 79 + Some(message) = metadata_rx.recv() => { 80 + app.handle_metadata(message); 81 + } 82 + } 83 + 84 + app.tick(); 85 + }; 86 + 87 + tui::restore()?; 88 + 89 + result 90 + } 91 + 92 + fn spawn_input_thread(tx: mpsc::UnboundedSender<Event>) { 93 + thread::spawn(move || loop { 94 + if crossterm::event::poll(Duration::from_millis(100)).unwrap_or(false) { 95 + if let Ok(event) = crossterm::event::read() { 96 + if tx.send(event).is_err() { 97 + break; 98 + } 99 + } 100 + } 101 + }); 102 + } 103 + 104 + struct HubApp { 105 + provider_name: String, 106 + provider: Box<dyn Provider>, 107 + audio: AudioController, 108 + favorites: FavoritesStore, 109 + ui: UiState, 110 + current_station: Option<StationRecord>, 111 + current_playback: Option<PlaybackState>, 112 + last_station: Option<StationRecord>, 113 + volume: f32, 114 + status: Option<StatusMessage>, 115 + metadata_tx: mpsc::UnboundedSender<HubMessage>, 116 + now_playing_station_id: Option<String>, 117 + next_now_playing_poll: Instant, 118 + os_media_controls: Option<OsMediaControls>, 119 + } 120 + 121 + impl HubApp { 122 + fn new( 123 + provider_name: String, 124 + provider: Box<dyn Provider>, 125 + audio: AudioController, 126 + favorites: FavoritesStore, 127 + metadata_tx: mpsc::UnboundedSender<HubMessage>, 128 + os_media_controls: Option<OsMediaControls>, 129 + ) -> Self { 130 + let mut ui = UiState::default(); 131 + ui.menu_state.select(Some(0)); 132 + Self { 133 + provider_name, 134 + provider, 135 + audio, 136 + favorites, 137 + ui, 138 + current_station: None, 139 + current_playback: None, 140 + last_station: None, 141 + volume: 100.0, 142 + status: None, 143 + metadata_tx, 144 + now_playing_station_id: None, 145 + next_now_playing_poll: Instant::now(), 146 + os_media_controls, 147 + } 148 + } 149 + 150 + fn render(&mut self, frame: &mut Frame) { 151 + let areas = Layout::default() 152 + .direction(Direction::Vertical) 153 + .constraints( 154 + [ 155 + Constraint::Length(8), 156 + Constraint::Length(1), 157 + Constraint::Min(0), 158 + Constraint::Length(1), 159 + ] 160 + .as_ref(), 161 + ) 162 + .split(frame.size()); 163 + 164 + self.render_header(frame, areas[0]); 165 + self.render_divider(frame, areas[1]); 166 + self.render_main(frame, areas[2]); 167 + frame.render_widget(self.render_footer(), areas[3]); 168 + } 169 + 170 + fn render_header(&self, frame: &mut Frame, area: Rect) { 171 + frame.render_widget( 172 + Block::new() 173 + .borders(Borders::TOP) 174 + .title(" TuneIn CLI ") 175 + .title_alignment(Alignment::Center), 176 + Rect { 177 + x: area.x, 178 + y: area.y, 179 + width: area.width, 180 + height: 1, 181 + }, 182 + ); 183 + 184 + let mut row = area.y + 1; 185 + 186 + frame.render_widget( 187 + Paragraph::new(format!("Provider {}", self.provider_name)), 188 + Rect { 189 + x: area.x, 190 + y: row, 191 + width: area.width, 192 + height: 1, 193 + }, 194 + ); 195 + row += 1; 196 + 197 + let station_name = self 198 + .current_playback 199 + .as_ref() 200 + .and_then(|p| { 201 + let name = p.stream_name.trim(); 202 + if name.is_empty() || name.eq_ignore_ascii_case("unknown") { 203 + let fallback = p.station.name.trim(); 204 + if fallback.is_empty() { 205 + None 206 + } else { 207 + Some(fallback.to_string()) 208 + } 209 + } else { 210 + Some(name.to_string()) 211 + } 212 + }) 213 + .or_else(|| { 214 + self.current_station.as_ref().and_then(|s| { 215 + let name = s.station.name.trim(); 216 + (!name.is_empty()).then_some(name.to_string()) 217 + }) 218 + }) 219 + .unwrap_or_else(|| "Unknown".to_string()); 220 + let station_id = self 221 + .current_playback 222 + .as_ref() 223 + .map(|p| p.station.id.as_str()) 224 + .or_else(|| self.current_station.as_ref().map(|s| s.station.id.as_str())) 225 + .unwrap_or("N/A"); 226 + 227 + self.render_labeled_line( 228 + frame, 229 + area, 230 + row, 231 + "Station ", 232 + &format!("{} - {}", station_name, station_id), 233 + ); 234 + row += 1; 235 + 236 + let now_playing = self 237 + .current_playback 238 + .as_ref() 239 + .and_then(|p| { 240 + let np = p.now_playing.trim(); 241 + (!np.is_empty()).then_some(np.to_string()) 242 + }) 243 + .or_else(|| { 244 + self.current_station 245 + .as_ref() 246 + .and_then(|s| s.station.playing.as_ref()) 247 + .map(|s| s.trim().to_string()) 248 + .filter(|s| !s.is_empty()) 249 + }) 250 + .unwrap_or_else(|| "โ€”".to_string()); 251 + self.render_labeled_line(frame, area, row, "Now Playing ", &now_playing); 252 + row += 1; 253 + 254 + let genre = self 255 + .current_playback 256 + .as_ref() 257 + .and_then(|p| { 258 + let genre = p.genre.trim(); 259 + (!genre.is_empty()).then_some(genre.to_string()) 260 + }) 261 + .unwrap_or_else(|| "Unknown".to_string()); 262 + self.render_labeled_line(frame, area, row, "Genre ", &genre); 263 + row += 1; 264 + 265 + let description = self 266 + .current_playback 267 + .as_ref() 268 + .and_then(|p| { 269 + let desc = p.description.trim(); 270 + (!desc.is_empty()).then_some(desc.to_string()) 271 + }) 272 + .unwrap_or_else(|| "Unknown".to_string()); 273 + self.render_labeled_line(frame, area, row, "Description ", &description); 274 + row += 1; 275 + 276 + let bitrate = self 277 + .current_playback 278 + .as_ref() 279 + .and_then(|p| { 280 + let br = p.bitrate.trim(); 281 + (!br.is_empty()).then_some(format!("{} kbps", br)) 282 + }) 283 + .or_else(|| { 284 + self.current_station.as_ref().and_then(|s| { 285 + (s.station.bitrate > 0).then_some(format!("{} kbps", s.station.bitrate)) 286 + }) 287 + }) 288 + .unwrap_or_else(|| "Unknown".to_string()); 289 + self.render_labeled_line(frame, area, row, "Bitrate ", &bitrate); 290 + row += 1; 291 + 292 + let volume_display = format!("{}%", self.volume as u32); 293 + self.render_labeled_line(frame, area, row, "Volume ", &volume_display); 294 + } 295 + 296 + fn render_labeled_line(&self, frame: &mut Frame, area: Rect, y: u16, label: &str, value: &str) { 297 + let span_label = Span::styled(label, Style::default().fg(Color::LightBlue)); 298 + let span_value = Span::raw(value); 299 + let line = Line::from(vec![span_label, span_value]); 300 + frame.render_widget( 301 + Paragraph::new(line), 302 + Rect { 303 + x: area.x, 304 + y, 305 + width: area.width, 306 + height: 1, 307 + }, 308 + ); 309 + } 310 + 311 + fn render_main(&mut self, frame: &mut Frame, area: Rect) { 312 + if matches!(self.ui.screen, Screen::Menu) { 313 + self.render_menu_area(frame, area); 314 + return; 315 + } 316 + 317 + let sections = Layout::default() 318 + .direction(Direction::Vertical) 319 + .constraints( 320 + [ 321 + Constraint::Min(0), 322 + Constraint::Length(1), 323 + Constraint::Length(5), 324 + ] 325 + .as_ref(), 326 + ) 327 + .split(area); 328 + 329 + self.render_non_menu_content(frame, sections[0]); 330 + self.render_divider(frame, sections[1]); 331 + self.render_feature_panel(frame, sections[2]); 332 + } 333 + 334 + fn render_non_menu_content(&mut self, frame: &mut Frame, area: Rect) { 335 + match &mut self.ui.screen { 336 + Screen::Menu => {} 337 + Screen::SearchInput => { 338 + let text = format!( 339 + "Search query: {}\n\nPress Enter to submit, Esc to cancel", 340 + self.ui.search_input 341 + ); 342 + let paragraph = Paragraph::new(text) 343 + .block(Block::default().title("Search").borders(Borders::ALL)); 344 + frame.render_widget(paragraph, area); 345 + } 346 + Screen::PlayInput => { 347 + let text = format!( 348 + "Station name or ID: {}\n\nPress Enter to submit, Esc to cancel", 349 + self.ui.play_input 350 + ); 351 + let paragraph = Paragraph::new(text) 352 + .block(Block::default().title("Play Station").borders(Borders::ALL)); 353 + frame.render_widget(paragraph, area); 354 + } 355 + Screen::SearchResults => { 356 + let items = Self::station_items(&self.ui.search_results); 357 + let list = List::new(items) 358 + .block( 359 + Block::default() 360 + .title(String::from("Search Results")) 361 + .borders(Borders::ALL), 362 + ) 363 + .highlight_symbol("โžœ ") 364 + .highlight_style( 365 + Style::default() 366 + .fg(Color::Yellow) 367 + .add_modifier(Modifier::BOLD), 368 + ); 369 + frame.render_stateful_widget(list, area, &mut self.ui.search_results_state); 370 + } 371 + Screen::Categories => { 372 + let items = Self::category_items(&self.ui.categories); 373 + let list = List::new(items) 374 + .block(Block::default().title("Categories").borders(Borders::ALL)) 375 + .highlight_symbol("โžœ ") 376 + .highlight_style( 377 + Style::default() 378 + .fg(Color::Yellow) 379 + .add_modifier(Modifier::BOLD), 380 + ); 381 + frame.render_stateful_widget(list, area, &mut self.ui.categories_state); 382 + } 383 + Screen::BrowseStations { category } => { 384 + let items = Self::station_items(&self.ui.browse_results); 385 + let list = List::new(items) 386 + .block( 387 + Block::default() 388 + .title(format!("Stations in {}", category)) 389 + .borders(Borders::ALL), 390 + ) 391 + .highlight_symbol("โžœ ") 392 + .highlight_style( 393 + Style::default() 394 + .fg(Color::Yellow) 395 + .add_modifier(Modifier::BOLD), 396 + ); 397 + frame.render_stateful_widget(list, area, &mut self.ui.browse_state); 398 + } 399 + Screen::Favourites => { 400 + let items = Self::favourite_items(self.favorites.all()); 401 + let list = List::new(items) 402 + .block(Block::default().title("Favourites").borders(Borders::ALL)) 403 + .highlight_symbol("โžœ ") 404 + .highlight_style( 405 + Style::default() 406 + .fg(Color::Yellow) 407 + .add_modifier(Modifier::BOLD), 408 + ); 409 + frame.render_stateful_widget(list, area, &mut self.ui.favourites_state); 410 + } 411 + Screen::Loading => { 412 + let message = self 413 + .ui 414 + .loading_message 415 + .as_deref() 416 + .unwrap_or("Loading, please waitโ€ฆ"); 417 + let paragraph = Paragraph::new(message) 418 + .block(Block::default().title("Loading").borders(Borders::ALL)) 419 + .alignment(Alignment::Center); 420 + frame.render_widget(paragraph, area); 421 + } 422 + } 423 + } 424 + 425 + fn render_divider(&self, frame: &mut Frame, area: Rect) { 426 + if area.width == 0 || area.height == 0 { 427 + return; 428 + } 429 + let width = area.width as usize; 430 + if width == 0 { 431 + return; 432 + } 433 + let mut line = String::with_capacity(width + 3); 434 + while line.len() < width { 435 + line.push_str("---"); 436 + } 437 + line.truncate(width); 438 + frame.render_widget(Paragraph::new(line), area); 439 + } 440 + 441 + fn render_feature_panel(&self, frame: &mut Frame, area: Rect) { 442 + if area.height == 0 || area.width == 0 { 443 + return; 444 + } 445 + 446 + let lines = self.feature_panel_lines(); 447 + let text = lines.join("\n"); 448 + let paragraph = 449 + Paragraph::new(text).block(Block::default().title("Actions").borders(Borders::ALL)); 450 + frame.render_widget(paragraph, area); 451 + } 452 + 453 + fn render_menu_area(&mut self, frame: &mut Frame, area: Rect) { 454 + if area.height == 0 || area.width == 0 { 455 + return; 456 + } 457 + let disable_resume = self.last_station.is_none(); 458 + let items: Vec<ListItem> = MENU_OPTIONS 459 + .iter() 460 + .map(|option| { 461 + if *option == "Resume Last Station" && disable_resume { 462 + ListItem::new(Line::from(Span::styled( 463 + *option, 464 + Style::default().fg(Color::DarkGray), 465 + ))) 466 + } else { 467 + ListItem::new(*option) 468 + } 469 + }) 470 + .collect(); 471 + let list = List::new(items) 472 + .block(Block::default().borders(Borders::ALL).title("Main Menu")) 473 + .highlight_style( 474 + Style::default() 475 + .fg(Color::Yellow) 476 + .add_modifier(Modifier::BOLD), 477 + ) 478 + .highlight_symbol("โžœ "); 479 + frame.render_stateful_widget(list, area, &mut self.ui.menu_state); 480 + } 481 + 482 + fn station_items(stations: &[Station]) -> Vec<ListItem<'_>> { 483 + if stations.is_empty() { 484 + vec![ListItem::new("No stations found")] 485 + } else { 486 + stations 487 + .iter() 488 + .map(|station| { 489 + let mut line = station.name.clone(); 490 + if let Some(now) = &station.playing { 491 + if !now.is_empty() { 492 + line.push_str(&format!(" โ€” {}", now)); 493 + } 494 + } 495 + ListItem::new(line) 496 + }) 497 + .collect() 498 + } 499 + } 500 + 501 + fn category_items(categories: &[String]) -> Vec<ListItem<'_>> { 502 + if categories.is_empty() { 503 + vec![ListItem::new("No categories available")] 504 + } else { 505 + categories 506 + .iter() 507 + .map(|category| ListItem::new(category.clone())) 508 + .collect() 509 + } 510 + } 511 + 512 + fn favourite_items(favourites: &[FavoriteStation]) -> Vec<ListItem<'_>> { 513 + if favourites.is_empty() { 514 + vec![ListItem::new("No favourites saved yet")] 515 + } else { 516 + favourites 517 + .iter() 518 + .map(|fav| ListItem::new(format!("{} ({})", fav.name, fav.provider))) 519 + .collect() 520 + } 521 + } 522 + 523 + fn handle_favourite_action(&mut self) -> Result<bool, Error> { 524 + match self.ui.screen { 525 + Screen::SearchResults => { 526 + let Some(index) = self.ui.search_results_state.selected() else { 527 + self.set_status("No search result selected"); 528 + return Ok(true); 529 + }; 530 + let station = self 531 + .ui 532 + .search_results 533 + .get(index) 534 + .cloned() 535 + .ok_or_else(|| anyhow!("Search result missing at index {}", index))?; 536 + self.add_station_to_favourites(station)?; 537 + Ok(true) 538 + } 539 + Screen::BrowseStations { .. } => { 540 + let Some(index) = self.ui.browse_state.selected() else { 541 + self.set_status("No station selected"); 542 + return Ok(true); 543 + }; 544 + let station = self 545 + .ui 546 + .browse_results 547 + .get(index) 548 + .cloned() 549 + .ok_or_else(|| anyhow!("Browse result missing at index {}", index))?; 550 + self.add_station_to_favourites(station)?; 551 + Ok(true) 552 + } 553 + Screen::Favourites => { 554 + let Some(index) = self.ui.favourites_state.selected() else { 555 + self.set_status("No favourite selected"); 556 + return Ok(true); 557 + }; 558 + self.remove_favourite_at(index)?; 559 + Ok(true) 560 + } 561 + _ => { 562 + self.toggle_current_favourite()?; 563 + Ok(true) 564 + } 565 + } 566 + } 567 + 568 + fn add_station_to_favourites(&mut self, station: Station) -> Result<(), Error> { 569 + if station.id.is_empty() { 570 + self.set_status("Cannot favourite station without an id"); 571 + return Ok(()); 572 + } 573 + 574 + let entry = FavoriteStation { 575 + id: station.id.clone(), 576 + name: station.name.clone(), 577 + provider: self.provider_name.clone(), 578 + }; 579 + 580 + if self.favorites.is_favorite(&entry.id, &entry.provider) { 581 + self.set_status("Already in favourites"); 582 + } else { 583 + self.favorites.add(entry)?; 584 + self.set_status(&format!("Added \"{}\" to favourites", station.name)); 585 + } 586 + Ok(()) 587 + } 588 + 589 + fn remove_favourite_at(&mut self, index: usize) -> Result<(), Error> { 590 + let Some(favourite) = self.favorites.all().get(index).cloned() else { 591 + self.set_status("Favourite not found"); 592 + return Ok(()); 593 + }; 594 + self.favorites.remove(&favourite.id, &favourite.provider)?; 595 + self.set_status(&format!("Removed \"{}\" from favourites", favourite.name)); 596 + 597 + let len = self.favorites.all().len(); 598 + if len == 0 { 599 + self.ui.favourites_state.select(None); 600 + } else { 601 + let new_index = index.min(len - 1); 602 + self.ui.favourites_state.select(Some(new_index)); 603 + } 604 + 605 + Ok(()) 606 + } 607 + 608 + fn stop_playback(&mut self) -> Result<(), Error> { 609 + self.audio.stop()?; 610 + self.set_status("Playback stopped"); 611 + Ok(()) 612 + } 613 + 614 + fn default_footer_hint(&self) -> String { 615 + match self.ui.screen { 616 + Screen::SearchResults => { 617 + "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f add to favourites โ€ข x stop playback โ€ข Esc back โ€ข +/- volume" 618 + .to_string() 619 + } 620 + Screen::Favourites => { 621 + "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f remove favourite โ€ข d/Delete remove โ€ข x stop playback โ€ข Esc back โ€ข +/- volume" 622 + .to_string() 623 + } 624 + Screen::Categories => { 625 + "โ†‘/โ†“ navigate โ€ข Enter open โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 626 + } 627 + Screen::BrowseStations { .. } => { 628 + "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f add to favourites โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 629 + } 630 + Screen::SearchInput | Screen::PlayInput => { 631 + "Type to edit โ€ข Enter submit โ€ข x stop playback โ€ข Esc cancel โ€ข +/- volume".to_string() 632 + } 633 + Screen::Loading => "Please waitโ€ฆ โ€ข x stop playback โ€ข Esc cancel โ€ข +/- volume".to_string(), 634 + Screen::Menu => { 635 + "โ†‘/โ†“ navigate โ€ข Enter select โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 636 + } 637 + } 638 + } 639 + 640 + fn feature_panel_lines(&self) -> Vec<String> { 641 + let mut lines = match self.ui.screen { 642 + Screen::SearchResults => vec![ 643 + "Search Results".to_string(), 644 + "Enter โ€ข Play highlighted station".to_string(), 645 + "f โ€ข Add highlighted station to favourites".to_string(), 646 + "Esc โ€ข Return to main menu".to_string(), 647 + ], 648 + Screen::Favourites => vec![ 649 + "Favourites".to_string(), 650 + "Enter โ€ข Play selected favourite".to_string(), 651 + "f โ€ข Remove highlighted favourite".to_string(), 652 + "d/Del โ€ข Remove highlighted favourite".to_string(), 653 + "Esc โ€ข Return to main menu".to_string(), 654 + ], 655 + Screen::BrowseStations { .. } => vec![ 656 + "Browse Stations".to_string(), 657 + "Enter โ€ข Play highlighted station".to_string(), 658 + "f โ€ข Add highlighted station to favourites".to_string(), 659 + "Esc โ€ข Back to categories".to_string(), 660 + ], 661 + Screen::Categories => vec![ 662 + "Categories".to_string(), 663 + "Enter โ€ข Drill into selected category".to_string(), 664 + "Esc โ€ข Return to main menu".to_string(), 665 + ], 666 + Screen::SearchInput => vec![ 667 + "Search".to_string(), 668 + "Enter โ€ข Run search".to_string(), 669 + "Esc โ€ข Cancel".to_string(), 670 + ], 671 + Screen::PlayInput => vec![ 672 + "Play Station".to_string(), 673 + "Enter โ€ข Start playback".to_string(), 674 + "Esc โ€ข Cancel".to_string(), 675 + ], 676 + Screen::Loading => vec!["Loadingโ€ฆ".to_string(), "Esc โ€ข Cancel".to_string()], 677 + Screen::Menu => vec![ 678 + "Main Menu".to_string(), 679 + "Enter โ€ข Activate highlighted option".to_string(), 680 + "Esc โ€ข Quit or back".to_string(), 681 + ], 682 + }; 683 + 684 + if self.current_station.is_some() { 685 + lines.insert(1, "x โ€ข Stop playback".to_string()); 686 + } else { 687 + lines.insert(1, "x โ€ข Stop playback (no active stream)".to_string()); 688 + } 689 + 690 + lines 691 + } 692 + 693 + fn render_footer(&self) -> Paragraph<'_> { 694 + let hint = self.default_footer_hint(); 695 + let text = if let Some(status) = &self.status { 696 + format!("{} โ€ข {}", status.message, hint) 697 + } else { 698 + hint 699 + }; 700 + Paragraph::new(text) 701 + } 702 + 703 + async fn handle_event(&mut self, event: Event) -> Result<Action, Error> { 704 + match event { 705 + Event::Key(key) => self.handle_key_event(key).await, 706 + Event::Resize(_, _) => Ok(Action::None), 707 + _ => Ok(Action::None), 708 + } 709 + } 710 + 711 + async fn handle_key_event(&mut self, key: KeyEvent) -> Result<Action, Error> { 712 + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { 713 + return Ok(Action::Quit); 714 + } 715 + 716 + match key.code { 717 + KeyCode::Char('+') | KeyCode::Char('=') => { 718 + self.adjust_volume(5.0)?; 719 + return Ok(Action::None); 720 + } 721 + KeyCode::Char('-') => { 722 + self.adjust_volume(-5.0)?; 723 + return Ok(Action::None); 724 + } 725 + KeyCode::Char('x') => { 726 + self.stop_playback()?; 727 + return Ok(Action::None); 728 + } 729 + KeyCode::Char('f') => { 730 + if self.handle_favourite_action()? { 731 + return Ok(Action::None); 732 + } 733 + } 734 + KeyCode::Esc if !matches!(self.ui.screen, Screen::Menu) => { 735 + self.ui.screen = Screen::Menu; 736 + return Ok(Action::None); 737 + } 738 + _ => {} 739 + } 740 + 741 + match self.ui.screen { 742 + Screen::Menu => self.handle_menu_keys(key), 743 + Screen::SearchInput => self.handle_text_input(key, true), 744 + Screen::PlayInput => self.handle_text_input(key, false), 745 + Screen::SearchResults => self.handle_station_list_keys(key, ListKind::Search), 746 + Screen::Categories => self.handle_categories_keys(key), 747 + Screen::BrowseStations { .. } => self.handle_station_list_keys(key, ListKind::Browse), 748 + Screen::Favourites => self.handle_favourites_keys(key), 749 + Screen::Loading => Ok(Action::None), 750 + } 751 + } 752 + 753 + fn handle_menu_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 754 + let current = self.ui.menu_state.selected().unwrap_or(0); 755 + match key.code { 756 + KeyCode::Up => { 757 + let new = current.saturating_sub(1); 758 + self.ui.menu_state.select(Some(new)); 759 + Ok(Action::None) 760 + } 761 + KeyCode::Down => { 762 + let max = MENU_OPTIONS.len().saturating_sub(1); 763 + let new = (current + 1).min(max); 764 + self.ui.menu_state.select(Some(new)); 765 + Ok(Action::None) 766 + } 767 + KeyCode::Enter => match MENU_OPTIONS[current] { 768 + "Search Stations" => { 769 + self.ui.search_input.clear(); 770 + self.ui.screen = Screen::SearchInput; 771 + Ok(Action::None) 772 + } 773 + "Browse Categories" => { 774 + self.ui.loading_message = Some("Fetching categoriesโ€ฆ".to_string()); 775 + self.ui.screen = Screen::Loading; 776 + Ok(Action::Task(PendingTask::LoadCategories)) 777 + } 778 + "Play Station" => { 779 + self.ui.play_input.clear(); 780 + self.ui.screen = Screen::PlayInput; 781 + Ok(Action::None) 782 + } 783 + "Favourites" => { 784 + self.ui.screen = Screen::Favourites; 785 + if self.favorites.all().is_empty() { 786 + self.ui.favourites_state.select(None); 787 + } else { 788 + self.ui.favourites_state.select(Some(0)); 789 + } 790 + Ok(Action::None) 791 + } 792 + "Resume Last Station" => { 793 + if let Some(station) = self.last_station.clone() { 794 + Ok(Action::Task(PendingTask::PlayStation(station))) 795 + } else { 796 + self.set_status("No station played yet to resume"); 797 + Ok(Action::None) 798 + } 799 + } 800 + "Quit" => Ok(Action::Quit), 801 + _ => Ok(Action::None), 802 + }, 803 + _ => Ok(Action::None), 804 + } 805 + } 806 + 807 + fn handle_text_input(&mut self, key: KeyEvent, is_search: bool) -> Result<Action, Error> { 808 + let buffer = if is_search { 809 + &mut self.ui.search_input 810 + } else { 811 + &mut self.ui.play_input 812 + }; 813 + 814 + match key.code { 815 + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { 816 + buffer.push(c); 817 + Ok(Action::None) 818 + } 819 + KeyCode::Backspace => { 820 + buffer.pop(); 821 + Ok(Action::None) 822 + } 823 + KeyCode::Enter => { 824 + if buffer.trim().is_empty() { 825 + self.set_status("Input cannot be empty"); 826 + return Ok(Action::None); 827 + } 828 + let query = buffer.trim().to_string(); 829 + self.ui.loading_message = Some("Searching stationsโ€ฆ".to_string()); 830 + self.ui.screen = Screen::Loading; 831 + if is_search { 832 + Ok(Action::Task(PendingTask::Search(query))) 833 + } else { 834 + Ok(Action::Task(PendingTask::PlayDirect(query))) 835 + } 836 + } 837 + _ => Ok(Action::None), 838 + } 839 + } 840 + 841 + fn handle_station_list_keys(&mut self, key: KeyEvent, kind: ListKind) -> Result<Action, Error> { 842 + let (items_len, state) = match kind { 843 + ListKind::Search => ( 844 + self.ui.search_results.len(), 845 + &mut self.ui.search_results_state, 846 + ), 847 + ListKind::Browse => (self.ui.browse_results.len(), &mut self.ui.browse_state), 848 + }; 849 + 850 + if items_len == 0 { 851 + if key.code == KeyCode::Esc { 852 + self.ui.screen = Screen::Menu; 853 + } 854 + return Ok(Action::None); 855 + } 856 + 857 + let current = state.selected().unwrap_or(0); 858 + match key.code { 859 + KeyCode::Up => { 860 + let new = current.saturating_sub(1); 861 + state.select(Some(new)); 862 + Ok(Action::None) 863 + } 864 + KeyCode::Down => { 865 + let max = items_len.saturating_sub(1); 866 + let new = (current + 1).min(max); 867 + state.select(Some(new)); 868 + Ok(Action::None) 869 + } 870 + KeyCode::Enter => { 871 + let station = match kind { 872 + ListKind::Search => self.ui.search_results[current].clone(), 873 + ListKind::Browse => self.ui.browse_results[current].clone(), 874 + }; 875 + Ok(Action::Task(PendingTask::PlayStation(StationRecord { 876 + provider: self.provider_name.clone(), 877 + station, 878 + }))) 879 + } 880 + KeyCode::Esc => { 881 + self.ui.screen = Screen::Menu; 882 + Ok(Action::None) 883 + } 884 + _ => Ok(Action::None), 885 + } 886 + } 887 + 888 + fn handle_categories_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 889 + let len = self.ui.categories.len(); 890 + if len == 0 { 891 + if key.code == KeyCode::Esc { 892 + self.ui.screen = Screen::Menu; 893 + } 894 + return Ok(Action::None); 895 + } 896 + 897 + let current = self.ui.categories_state.selected().unwrap_or(0); 898 + match key.code { 899 + KeyCode::Up => { 900 + let new = current.saturating_sub(1); 901 + self.ui.categories_state.select(Some(new)); 902 + Ok(Action::None) 903 + } 904 + KeyCode::Down => { 905 + let max = len.saturating_sub(1); 906 + let new = (current + 1).min(max); 907 + self.ui.categories_state.select(Some(new)); 908 + Ok(Action::None) 909 + } 910 + KeyCode::Enter => { 911 + let category = self.ui.categories[current].clone(); 912 + self.ui.loading_message = Some(format!("Loading stations for {}โ€ฆ", category)); 913 + self.ui.screen = Screen::Loading; 914 + Ok(Action::Task(PendingTask::LoadCategoryStations { category })) 915 + } 916 + KeyCode::Esc => { 917 + self.ui.screen = Screen::Menu; 918 + Ok(Action::None) 919 + } 920 + _ => Ok(Action::None), 921 + } 922 + } 923 + 924 + fn handle_favourites_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 925 + let len = self.favorites.all().len(); 926 + if len == 0 { 927 + if key.code == KeyCode::Esc { 928 + self.ui.screen = Screen::Menu; 929 + } 930 + return Ok(Action::None); 931 + } 932 + 933 + let current = self.ui.favourites_state.selected().unwrap_or(0); 934 + match key.code { 935 + KeyCode::Up => { 936 + let new = current.saturating_sub(1); 937 + self.ui.favourites_state.select(Some(new)); 938 + Ok(Action::None) 939 + } 940 + KeyCode::Down => { 941 + let max = len.saturating_sub(1); 942 + let new = (current + 1).min(max); 943 + self.ui.favourites_state.select(Some(new)); 944 + Ok(Action::None) 945 + } 946 + KeyCode::Enter => { 947 + let favourite = self.favorites.all()[current].clone(); 948 + Ok(Action::Task(PendingTask::PlayFavourite(favourite))) 949 + } 950 + KeyCode::Delete | KeyCode::Char('d') | KeyCode::Char('f') => { 951 + self.remove_favourite_at(current)?; 952 + Ok(Action::None) 953 + } 954 + KeyCode::Esc => { 955 + self.ui.screen = Screen::Menu; 956 + Ok(Action::None) 957 + } 958 + _ => Ok(Action::None), 959 + } 960 + } 961 + 962 + fn adjust_volume(&mut self, delta: f32) -> Result<(), Error> { 963 + self.volume = (self.volume + delta).clamp(0.0, 150.0); 964 + self.audio.set_volume(self.volume)?; 965 + self.set_status(&format!("Volume set to {}%", self.volume as u32)); 966 + Ok(()) 967 + } 968 + 969 + fn toggle_current_favourite(&mut self) -> Result<(), Error> { 970 + let Some(station) = &self.current_station else { 971 + self.set_status("No active station to favourite"); 972 + return Ok(()); 973 + }; 974 + 975 + if station.station.id.is_empty() { 976 + self.set_status("Current station cannot be favourited"); 977 + return Ok(()); 978 + } 979 + 980 + let entry = FavoriteStation { 981 + id: station.station.id.clone(), 982 + name: station.station.name.clone(), 983 + provider: station.provider.clone(), 984 + }; 985 + let added = self.favorites.toggle(entry)?; 986 + if added { 987 + self.set_status("Added to favourites"); 988 + } else { 989 + self.set_status("Removed from favourites"); 990 + } 991 + Ok(()) 992 + } 993 + 994 + fn handle_playback_event(&mut self, event: PlaybackEvent) { 995 + match event { 996 + PlaybackEvent::Started(state) => { 997 + self.current_playback = Some(state.clone()); 998 + if let Some(station) = self.current_station.as_mut() { 999 + station.station.playing = Some(state.now_playing.clone()); 1000 + station.station.id = state.station.id.clone(); 1001 + } 1002 + self.set_status(&format!("Now playing {}", state.stream_name)); 1003 + self.prepare_now_playing_poll(); 1004 + } 1005 + PlaybackEvent::Error(err) => { 1006 + self.current_playback = None; 1007 + self.set_status(&format!("Playback error: {}", err)); 1008 + self.now_playing_station_id = None; 1009 + } 1010 + PlaybackEvent::Stopped => { 1011 + self.current_playback = None; 1012 + self.set_status("Playback stopped"); 1013 + self.now_playing_station_id = None; 1014 + } 1015 + } 1016 + } 1017 + 1018 + fn handle_metadata(&mut self, message: HubMessage) { 1019 + match message { 1020 + HubMessage::NowPlaying(now_playing) => { 1021 + if let Some(playback) = self.current_playback.as_mut() { 1022 + playback.now_playing = now_playing.clone(); 1023 + } 1024 + if let Some(station) = self.current_station.as_mut() { 1025 + station.station.playing = Some(now_playing.clone()); 1026 + } 1027 + self.set_status(&format!("Now Playing {}", now_playing)); 1028 + 1029 + let name = self 1030 + .current_station 1031 + .as_ref() 1032 + .map(|s| s.station.name.clone()) 1033 + .unwrap_or_default(); 1034 + 1035 + send_os_media_controls_command( 1036 + self.os_media_controls.as_mut(), 1037 + os_media_controls::Command::SetMetadata(souvlaki::MediaMetadata { 1038 + title: (!now_playing.is_empty()).then_some(now_playing.as_str()), 1039 + album: (!name.is_empty()).then_some(name.as_str()), 1040 + artist: None, 1041 + cover_url: None, 1042 + duration: None, 1043 + }), 1044 + ); 1045 + } 1046 + } 1047 + } 1048 + 1049 + async fn perform_task(&mut self, task: PendingTask) -> Result<(), Error> { 1050 + self.ui.loading_message = None; 1051 + match task { 1052 + PendingTask::Search(query) => { 1053 + let results = self.provider.search(query.clone()).await?; 1054 + self.ui.search_results = results; 1055 + self.ui.search_results_state.select(Some(0)); 1056 + self.ui.screen = Screen::SearchResults; 1057 + self.set_status(&format!("Search complete for \"{}\"", query)); 1058 + } 1059 + PendingTask::LoadCategories => { 1060 + let categories = self.provider.categories(0, 100).await?; 1061 + self.ui.categories = categories; 1062 + self.ui.categories_state.select(Some(0)); 1063 + self.ui.screen = Screen::Categories; 1064 + self.set_status("Categories loaded"); 1065 + } 1066 + PendingTask::LoadCategoryStations { category } => { 1067 + let stations = self.provider.browse(category.clone(), 0, 100).await?; 1068 + self.ui.browse_results = stations; 1069 + self.ui.browse_state.select(Some(0)); 1070 + self.ui.screen = Screen::BrowseStations { category }; 1071 + self.set_status("Stations loaded"); 1072 + } 1073 + PendingTask::PlayDirect(input) => { 1074 + let provider = resolve_provider(&self.provider_name).await?; 1075 + match provider.get_station(input.clone()).await? { 1076 + Some(mut station) => { 1077 + if station.stream_url.is_empty() { 1078 + station = fetch_station(&self.provider_name, &station.id) 1079 + .await? 1080 + .ok_or_else(|| anyhow!("Unable to locate stream for station"))?; 1081 + } 1082 + self.play_station(StationRecord { 1083 + provider: self.provider_name.clone(), 1084 + station, 1085 + }) 1086 + .await?; 1087 + } 1088 + None => { 1089 + self.ui.screen = Screen::Menu; 1090 + self.set_status(&format!("Station \"{}\" not found", input)); 1091 + } 1092 + } 1093 + } 1094 + PendingTask::PlayStation(record) => { 1095 + self.play_station(record).await?; 1096 + } 1097 + PendingTask::PlayFavourite(favourite) => { 1098 + let station = fetch_station(&favourite.provider, &favourite.id) 1099 + .await? 1100 + .ok_or_else(|| anyhow!("Failed to load favourite station"))?; 1101 + self.play_station(StationRecord { 1102 + provider: favourite.provider, 1103 + station, 1104 + }) 1105 + .await?; 1106 + } 1107 + } 1108 + Ok(()) 1109 + } 1110 + 1111 + async fn play_station(&mut self, mut record: StationRecord) -> Result<(), Error> { 1112 + if record.station.stream_url.is_empty() { 1113 + if let Some(enriched) = fetch_station(&record.provider, &record.station.id).await? { 1114 + record.station = enriched; 1115 + } else { 1116 + return Err(anyhow!("Unable to resolve station stream")); 1117 + } 1118 + } 1119 + 1120 + self.audio.play(record.station.clone(), self.volume)?; 1121 + self.current_station = Some(record.clone()); 1122 + self.last_station = Some(record); 1123 + self.prepare_now_playing_poll(); 1124 + self.ui.screen = Screen::Menu; 1125 + Ok(()) 1126 + } 1127 + 1128 + fn prepare_now_playing_poll(&mut self) { 1129 + if let Some(station) = &self.current_station { 1130 + if station.provider == "tunein" && !station.station.id.is_empty() { 1131 + self.now_playing_station_id = Some(station.station.id.clone()); 1132 + self.next_now_playing_poll = Instant::now(); 1133 + } else { 1134 + self.now_playing_station_id = None; 1135 + } 1136 + } 1137 + } 1138 + 1139 + fn tick(&mut self) { 1140 + if let Some(status) = &self.status { 1141 + if status.expires_at <= Instant::now() { 1142 + self.status = None; 1143 + } 1144 + } 1145 + self.poll_now_playing_if_needed(); 1146 + } 1147 + 1148 + fn poll_now_playing_if_needed(&mut self) { 1149 + let Some(station_id) = self.now_playing_station_id.clone() else { 1150 + return; 1151 + }; 1152 + 1153 + if Instant::now() < self.next_now_playing_poll { 1154 + return; 1155 + } 1156 + 1157 + let tx = self.metadata_tx.clone(); 1158 + tokio::spawn(async move { 1159 + if let Ok(now) = get_currently_playing(&station_id).await { 1160 + let _ = tx.send(HubMessage::NowPlaying(now)); 1161 + } 1162 + }); 1163 + 1164 + self.next_now_playing_poll = Instant::now() + NOW_PLAYING_POLL_INTERVAL; 1165 + } 1166 + 1167 + fn set_status<S: Into<String>>(&mut self, message: S) { 1168 + self.status = Some(StatusMessage { 1169 + message: message.into(), 1170 + expires_at: Instant::now() + STATUS_TIMEOUT, 1171 + }); 1172 + } 1173 + } 1174 + 1175 + struct UiState { 1176 + screen: Screen, 1177 + menu_state: ListState, 1178 + search_input: String, 1179 + play_input: String, 1180 + search_results: Vec<Station>, 1181 + search_results_state: ListState, 1182 + categories: Vec<String>, 1183 + categories_state: ListState, 1184 + browse_results: Vec<Station>, 1185 + browse_state: ListState, 1186 + favourites_state: ListState, 1187 + loading_message: Option<String>, 1188 + } 1189 + 1190 + impl Default for UiState { 1191 + fn default() -> Self { 1192 + Self { 1193 + screen: Screen::Menu, 1194 + menu_state: ListState::default(), 1195 + search_input: String::new(), 1196 + play_input: String::new(), 1197 + search_results: Vec::new(), 1198 + search_results_state: ListState::default(), 1199 + categories: Vec::new(), 1200 + categories_state: ListState::default(), 1201 + browse_results: Vec::new(), 1202 + browse_state: ListState::default(), 1203 + favourites_state: ListState::default(), 1204 + loading_message: None, 1205 + } 1206 + } 1207 + } 1208 + 1209 + #[derive(Clone)] 1210 + enum Screen { 1211 + Menu, 1212 + SearchInput, 1213 + PlayInput, 1214 + SearchResults, 1215 + Categories, 1216 + BrowseStations { category: String }, 1217 + Favourites, 1218 + Loading, 1219 + } 1220 + 1221 + enum ListKind { 1222 + Search, 1223 + Browse, 1224 + } 1225 + 1226 + enum PendingTask { 1227 + Search(String), 1228 + LoadCategories, 1229 + LoadCategoryStations { category: String }, 1230 + PlayDirect(String), 1231 + PlayStation(StationRecord), 1232 + PlayFavourite(FavoriteStation), 1233 + } 1234 + 1235 + enum Action { 1236 + None, 1237 + Quit, 1238 + Task(PendingTask), 1239 + } 1240 + 1241 + struct StatusMessage { 1242 + message: String, 1243 + expires_at: Instant, 1244 + } 1245 + 1246 + #[derive(Clone)] 1247 + struct StationRecord { 1248 + provider: String, 1249 + station: Station, 1250 + } 1251 + 1252 + async fn resolve_provider(name: &str) -> Result<Box<dyn Provider>, Error> { 1253 + match name { 1254 + "tunein" => Ok(Box::new(Tunein::new())), 1255 + "radiobrowser" => Ok(Box::new(Radiobrowser::new().await)), 1256 + other => Err(anyhow!("Unsupported provider '{}'", other)), 1257 + } 1258 + } 1259 + 1260 + async fn fetch_station(provider_name: &str, id: &str) -> Result<Option<Station>, Error> { 1261 + let provider = resolve_provider(provider_name).await?; 1262 + provider.get_station(id.to_string()).await 1263 + }
+17 -8
src/main.rs
··· 5 use clap::{arg, builder::ValueParser, Command}; 6 7 mod app; 8 mod browse; 9 mod cfg; 10 mod decoder; 11 mod extract; 12 mod input; 13 mod music; 14 mod play; 15 mod player; ··· 39 .arg( 40 arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein") 41 ) 42 - .subcommand_required(true) 43 .subcommand( 44 Command::new("search") 45 .about("Search for a radio station") ··· 88 #[tokio::main] 89 async fn main() -> Result<(), Error> { 90 let matches = cli().get_matches(); 91 92 match matches.subcommand() { 93 Some(("search", args)) => { 94 let query = args.value_of("query").unwrap(); 95 - let provider = matches.value_of("provider").unwrap(); 96 - search::exec(query, provider).await?; 97 } 98 Some(("play", args)) => { 99 let station = args.value_of("station").unwrap(); 100 - let provider = matches.value_of("provider").unwrap(); 101 let volume = args.value_of("volume").unwrap().parse::<f32>().unwrap(); 102 let display_mode = args 103 .value_of("display-mode") ··· 115 ); 116 play::exec( 117 station, 118 - provider, 119 volume, 120 display_mode, 121 *enable_os_media_controls, ··· 128 let category = args.value_of("category"); 129 let offset = args.value_of("offset").unwrap(); 130 let limit = args.value_of("limit").unwrap(); 131 - let provider = matches.value_of("provider").unwrap(); 132 browse::exec( 133 category, 134 offset.parse::<u32>()?, 135 limit.parse::<u32>()?, 136 - provider, 137 ) 138 .await?; 139 } ··· 151 std::process::exit(1); 152 } 153 }, 154 - _ => unreachable!(), 155 } 156 157 Ok(())
··· 5 use clap::{arg, builder::ValueParser, Command}; 6 7 mod app; 8 + mod audio; 9 mod browse; 10 mod cfg; 11 mod decoder; 12 mod extract; 13 + mod favorites; 14 mod input; 15 + mod interactive; 16 mod music; 17 mod play; 18 mod player; ··· 42 .arg( 43 arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein") 44 ) 45 .subcommand( 46 Command::new("search") 47 .about("Search for a radio station") ··· 90 #[tokio::main] 91 async fn main() -> Result<(), Error> { 92 let matches = cli().get_matches(); 93 + let provider = matches.value_of("provider").unwrap().to_string(); 94 95 match matches.subcommand() { 96 Some(("search", args)) => { 97 let query = args.value_of("query").unwrap(); 98 + search::exec(query, provider.as_str()).await?; 99 } 100 Some(("play", args)) => { 101 let station = args.value_of("station").unwrap(); 102 let volume = args.value_of("volume").unwrap().parse::<f32>().unwrap(); 103 let display_mode = args 104 .value_of("display-mode") ··· 116 ); 117 play::exec( 118 station, 119 + provider.as_str(), 120 volume, 121 display_mode, 122 *enable_os_media_controls, ··· 129 let category = args.value_of("category"); 130 let offset = args.value_of("offset").unwrap(); 131 let limit = args.value_of("limit").unwrap(); 132 browse::exec( 133 category, 134 offset.parse::<u32>()?, 135 limit.parse::<u32>()?, 136 + provider.as_str(), 137 ) 138 .await?; 139 } ··· 151 std::process::exit(1); 152 } 153 }, 154 + None => { 155 + interactive::run(provider.as_str()).await?; 156 + } 157 + Some((other, _)) => { 158 + eprintln!( 159 + "Unknown subcommand '{}'. Use `tunein --help` for available commands.", 160 + other 161 + ); 162 + std::process::exit(1); 163 + } 164 } 165 166 Ok(())
+4 -3
src/play.rs
··· 1 - use std::{thread, time::Duration}; 2 3 use anyhow::Error; 4 use hyper::header::HeaderValue; ··· 143 let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); 144 let sink = rodio::Sink::try_new(&handle).unwrap(); 145 sink.set_volume(volume.volume_ratio()); 146 - let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); 147 sink.append(decoder); 148 149 loop { ··· 167 let mut terminal = tui::init()?; 168 app.run(&mut terminal, cmd_rx, sink_cmd_tx, &id).await; 169 tui::restore()?; 170 - Ok(()) 171 } 172 173 /// Command for a sink.
··· 1 + use std::{process, thread, time::Duration}; 2 3 use anyhow::Error; 4 use hyper::header::HeaderValue; ··· 143 let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); 144 let sink = rodio::Sink::try_new(&handle).unwrap(); 145 sink.set_volume(volume.volume_ratio()); 146 + let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); 147 sink.append(decoder); 148 149 loop { ··· 167 let mut terminal = tui::init()?; 168 app.run(&mut terminal, cmd_rx, sink_cmd_tx, &id).await; 169 tui::restore()?; 170 + 171 + process::exit(0); 172 } 173 174 /// Command for a sink.
+2 -2
src/player.rs
··· 61 let sink = self.sink.clone(); 62 63 thread::spawn(move || { 64 - let (frame_tx, frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>(); 65 let client = reqwest::blocking::Client::new(); 66 67 let response = client.get(url.clone()).send().unwrap(); ··· 80 } 81 None => response, 82 }; 83 - let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); 84 85 { 86 let sink = sink.lock().unwrap();
··· 61 let sink = self.sink.clone(); 62 63 thread::spawn(move || { 64 + let (frame_tx, _frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>(); 65 let client = reqwest::blocking::Client::new(); 66 67 let response = client.get(url.clone()).send().unwrap(); ··· 80 } 81 None => response, 82 }; 83 + let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); 84 85 { 86 let sink = sink.lock().unwrap();
+6 -1
src/provider/tunein.rs
··· 55 None => Ok(None), 56 } 57 } 58 - _ => Ok(Some(Station::from(stations[0].clone()))), 59 } 60 } 61
··· 55 None => Ok(None), 56 } 57 } 58 + _ => { 59 + let mut station = Station::from(stations[0].clone()); 60 + // Preserve the original station ID since StationLinkDetails doesn't contain it 61 + station.id = id; 62 + Ok(Some(station)) 63 + } 64 } 65 } 66
+2 -1
src/visualization/mod.rs
··· 20 pub struct GraphConfig { 21 pub pause: bool, 22 pub samples: u32, 23 pub sampling_rate: u32, 24 pub scale: f64, 25 pub width: u32, ··· 47 fn from_args(args: &crate::cfg::SourceOptions) -> Self 48 where 49 Self: Sized; 50 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this 51 fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>; 52 fn mode_str(&self) -> &'static str; 53
··· 20 pub struct GraphConfig { 21 pub pause: bool, 22 pub samples: u32, 23 + #[allow(dead_code)] 24 pub sampling_rate: u32, 25 pub scale: f64, 26 pub width: u32, ··· 48 fn from_args(args: &crate::cfg::SourceOptions) -> Self 49 where 50 Self: Sized; 51 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_>; // TODO simplify this 52 fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>; 53 fn mode_str(&self) -> &'static str; 54
+1 -1
src/visualization/oscilloscope.rs
··· 55 } 56 } 57 58 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 59 let (name, bounds) = match dimension { 60 Dimension::X => ("time -", [0.0, cfg.samples as f64]), 61 Dimension::Y => ("| amplitude", [-cfg.scale, cfg.scale]),
··· 55 } 56 } 57 58 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 59 let (name, bounds) = match dimension { 60 Dimension::X => ("time -", [0.0, cfg.samples as f64]), 61 Dimension::Y => ("| amplitude", [-cfg.scale, cfg.scale]),
+1 -1
src/visualization/spectroscope.rs
··· 84 } 85 } 86 87 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 88 let (name, bounds) = match dimension { 89 Dimension::X => ( 90 "frequency -",
··· 84 } 85 } 86 87 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 88 let (name, bounds) = match dimension { 89 Dimension::X => ( 90 "frequency -",
+1 -1
src/visualization/vectorscope.rs
··· 28 "live".into() 29 } 30 31 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 32 let (name, bounds) = match dimension { 33 Dimension::X => ("left -", [-cfg.scale, cfg.scale]), 34 Dimension::Y => ("| right", [-cfg.scale, cfg.scale]),
··· 28 "live".into() 29 } 30 31 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 32 let (name, bounds) = match dimension { 33 Dimension::X => ("left -", [-cfg.scale, cfg.scale]), 34 Dimension::Y => ("| right", [-cfg.scale, cfg.scale]),