+1
-1
.fluentci/mod.ts
+1
-1
.fluentci/mod.ts
-39
.fluentci/src/aws/README.md
-39
.fluentci/src/aws/README.md
···
1
-
# AWS CodePipeline
2
-
3
-
[](https://pkg.fluentci.io/rust_pipeline)
4
-
[](https://deno.land/x/rust_pipeline)
5
-

6
-
[](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
-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
-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
-3
.fluentci/src/aws/init.ts
-42
.fluentci/src/azure/README.md
-42
.fluentci/src/azure/README.md
···
1
-
# Azure Pipelines
2
-
3
-
[](https://pkg.fluentci.io/rust_pipeline)
4
-
[](https://deno.land/x/rust_pipeline)
5
-

6
-
[](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
-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
-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
-3
.fluentci/src/azure/init.ts
-47
.fluentci/src/circleci/README.md
-47
.fluentci/src/circleci/README.md
···
1
-
# Circle CI
2
-
3
-
[](https://pkg.fluentci.io/rust_pipeline)
4
-
[](https://deno.land/x/rust_pipeline)
5
-

6
-
[](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
-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
-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
-3
.fluentci/src/circleci/init.ts
-4
.fluentci/src/dagger/index.ts
-4
.fluentci/src/dagger/index.ts
-177
.fluentci/src/dagger/jobs.ts
-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
-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
-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
-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
-3
.fluentci/src/dagger/runner.ts
-50
.fluentci/src/github/README.md
-50
.fluentci/src/github/README.md
···
1
-
# Github Actions
2
-
3
-
[](https://pkg.fluentci.io/rust_pipeline)
4
-
[](https://deno.land/x/rust_pipeline)
5
-

6
-
[](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
-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
-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
-3
.fluentci/src/github/init.ts
-48
.fluentci/src/gitlab/README.md
-48
.fluentci/src/gitlab/README.md
···
1
-
# Gitlab CI
2
-
3
-
[](https://pkg.fluentci.io/rust_pipeline)
4
-
[](https://deno.land/x/rust_pipeline)
5
-

6
-
[](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
-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
-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
-3
.fluentci/src/gitlab/init.ts
+434
.fluentci/src/jobs.ts
+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
+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
+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
+4
.fluentci/src/mod.ts
+24
.fluentci/src/pipeline.ts
+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
+3
.fluentci/src/runner.ts
+2
.tangled/workflows/fmt.yml
+2
.tangled/workflows/fmt.yml
+52
-2
Cargo.lock
+52
-2
Cargo.lock
···
1065
1065
]
1066
1066
1067
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]]
1068
1089
name = "discard"
1069
1090
version = "1.0.4"
1070
1091
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2147
2168
]
2148
2169
2149
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]]
2150
2181
name = "linked-hash-map"
2151
2182
version = "0.5.6"
2152
2183
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2599
2630
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
2600
2631
2601
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]]
2602
2639
name = "os_str_bytes"
2603
2640
version = "6.6.1"
2604
2641
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3024
3061
checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb"
3025
3062
3026
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]]
3027
3075
name = "regex"
3028
3076
version = "1.11.1"
3029
3077
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3963
4011
checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d"
3964
4012
dependencies = [
3965
4013
"libc",
3966
-
"libredox",
4014
+
"libredox 0.0.2",
3967
4015
"numtoa",
3968
4016
"redox_termios",
3969
4017
]
···
4358
4406
4359
4407
[[package]]
4360
4408
name = "tunein-cli"
4361
-
version = "0.3.2"
4409
+
version = "0.4.1"
4362
4410
dependencies = [
4363
4411
"anyhow",
4364
4412
"async-trait",
···
4366
4414
"cpal",
4367
4415
"crossterm",
4368
4416
"derive_more",
4417
+
"directories",
4369
4418
"futures",
4370
4419
"futures-util",
4371
4420
"hyper 0.14.32",
···
4381
4430
"rodio",
4382
4431
"rustfft",
4383
4432
"serde",
4433
+
"serde_json",
4384
4434
"souvlaki",
4385
4435
"surf",
4386
4436
"symphonia",
+8
-2
Cargo.toml
+8
-2
Cargo.toml
···
8
8
name = "tunein-cli"
9
9
readme = "README.md"
10
10
repository = "https://github.com/tsirysndr/tunein-cli"
11
-
version = "0.3.2"
11
+
version = "0.4.1"
12
12
13
13
[[bin]]
14
14
name = "tunein"
···
20
20
"apt-get update && apt-get --assume-yes install libasound2-dev libasound2-dev:$CROSS_DEB_ARCH protobuf-compiler",
21
21
]
22
22
23
+
[profile.release]
24
+
codegen-units = 1
25
+
lto = true
26
+
23
27
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
24
28
25
29
[dependencies]
···
41
45
m3u = "1.0.0"
42
46
minimp3 = "0.6"
43
47
owo-colors = "3.5.0"
48
+
directories = "5.0.1"
44
49
pls = "0.2.2"
45
50
prost = "0.13.2"
46
51
radiobrowser = { version = "0.6.1", features = [
···
54
59
], default-features = false }
55
60
rodio = { version = "0.16" }
56
61
rustfft = "6.2.0"
57
-
serde = "1.0.197"
62
+
serde = { version = "1.0.197", features = ["derive"] }
63
+
serde_json = "1.0.117"
58
64
surf = { version = "2.3.2", features = [
59
65
"h1-client-rustls",
60
66
], default-features = false }
+3
-3
README.md
+3
-3
README.md
···
36
36
```bash
37
37
# Install dependencies
38
38
brew install protobuf # macOS
39
-
sudo apt-get install -y libasound2-dev protobuf-compiler # Ubuntu/Debian
39
+
sudo apt-get install -y libasound2-dev protobuf-compiler libdbus-1-dev # Ubuntu/Debian
40
40
# Compile and install
41
41
git clone https://github.com/tsirysndr/tunein-cli
42
42
cd tunein-cli
···
108
108
Or download the latest release for your platform [here](https://github.com/tsirysndr/tunein-cli/releases).
109
109
110
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)
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
113
114
114
## ๐ Usage
115
115
```
+1
build.rs
+1
build.rs
+2
-2
dist/debian/amd64/DEBIAN/control
+2
-2
dist/debian/amd64/DEBIAN/control
···
1
1
Package: tunein-cli
2
-
Version: 0.3.2
2
+
Version: 0.4.1
3
3
Section: user/multimedia
4
4
Priority: optional
5
5
Architecture: amd64
6
6
Maintainer: Tsiry Sandratraina <tsiry.sndr@fluentci.io>
7
-
Depends: alsa-utils, libasound2-dev
7
+
Depends: alsa-utils, libasound2-dev, libdbus-1-3
8
8
Description: Browse and listen to thousands of radio stations across the globe right from your terminal ๐ ๐ป ๐ตโจ
+2
-2
dist/debian/arm64/DEBIAN/control
+2
-2
dist/debian/arm64/DEBIAN/control
···
1
1
Package: tunein-cli
2
-
Version: 0.3.2
2
+
Version: 0.4.1
3
3
Section: user/multimedia
4
4
Priority: optional
5
5
Architecture: arm64
6
6
Maintainer: Tsiry Sandratraina <tsiry.sndr@fluentci.io>
7
-
Depends: alsa-utils, libasound2-dev
7
+
Depends: alsa-utils, libasound2-dev, libdbus-1-3
8
8
Description: Browse and listen to thousands of radio stations across the globe right from your terminal ๐ ๐ป ๐ตโจ
9
9
+2
-2
dist/rpm/amd64/tunein.spec
+2
-2
dist/rpm/amd64/tunein.spec
···
1
1
Name: tunein-cli
2
-
Version: 0.3.2
2
+
Version: 0.4.1
3
3
Release: 1%{?dist}
4
4
Summary: CLI for listening to internet radio stations
5
5
···
7
7
8
8
BuildArch: x86_64
9
9
10
-
Requires: alsa-utils, alsa-lib-devel
10
+
Requires: alsa-utils, alsa-lib-devel, dbus-libs
11
11
12
12
%description
13
13
Browse and listen to thousands of radio stations across the globe right from your terminal ๐ ๐ป ๐ตโจ
+2
-2
dist/rpm/arm64/tunein.spec
+2
-2
dist/rpm/arm64/tunein.spec
···
1
1
2
2
Name: tunein-cli
3
-
Version: 0.3.2
3
+
Version: 0.4.1
4
4
Release: 1%{?dist}
5
5
Summary: CLI for listening to internet radio stations
6
6
···
8
8
9
9
BuildArch: aarch64
10
10
11
-
Requires: alsa-utils, alsa-lib-devel
11
+
Requires: alsa-utils, alsa-lib-devel, dbus-libs
12
12
13
13
%description
14
14
Browse and listen to thousands of radio stations across the globe right from your terminal ๐ ๐ป ๐ตโจ
+16
-98
flake.lock
+16
-98
flake.lock
···
3
3
"advisory-db": {
4
4
"flake": false,
5
5
"locked": {
6
-
"lastModified": 1688041319,
7
-
"narHash": "sha256-J4lJWSRTOvXDS/Tckj+/5RvAnPCK+qQUMNZhsojR1SM=",
6
+
"lastModified": 1756294590,
7
+
"narHash": "sha256-CyhicqYGMUCtBAbsyMKIuQVYl5D7m+yh/E0wonouF+A=",
8
8
"owner": "rustsec",
9
9
"repo": "advisory-db",
10
-
"rev": "1f538e6f3b8ad37e89b1386e06be080fbe474b3c",
10
+
"rev": "11793a852bab94279ef3efc26d69face55a9e2ba",
11
11
"type": "github"
12
12
},
13
13
"original": {
···
17
17
}
18
18
},
19
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
20
"locked": {
29
-
"lastModified": 1688425221,
30
-
"narHash": "sha256-DhZnju72DuX9GhOnCOBIE94aCGKC2BOaF+kGxbnP/K0=",
21
+
"lastModified": 1755993354,
22
+
"narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=",
31
23
"owner": "ipetkov",
32
24
"repo": "crane",
33
-
"rev": "fc6a236548b31aef0be3b0a0377c4459bb39d923",
25
+
"rev": "25bd41b24426c7734278c2ff02e53258851db914",
34
26
"type": "github"
35
27
},
36
28
"original": {
···
47
39
"rust-analyzer-src": []
48
40
},
49
41
"locked": {
50
-
"lastModified": 1688451945,
51
-
"narHash": "sha256-87ecJNdUQye1/gV5i8ptqrfYCcO1r0jxDGtfJVGFZ7s=",
42
+
"lastModified": 1755585599,
43
+
"narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=",
52
44
"owner": "nix-community",
53
45
"repo": "fenix",
54
-
"rev": "6fabc883d4b00eb05af2fe630f18b5ffcfe7f811",
46
+
"rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42",
55
47
"type": "github"
56
48
},
57
49
"original": {
···
60
52
"type": "github"
61
53
}
62
54
},
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
55
"flake-utils": {
80
56
"inputs": {
81
57
"systems": "systems"
82
58
},
83
59
"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=",
60
+
"lastModified": 1731533236,
61
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
104
62
"owner": "numtide",
105
63
"repo": "flake-utils",
106
-
"rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7",
64
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
107
65
"type": "github"
108
66
},
109
67
"original": {
···
114
72
},
115
73
"nixpkgs": {
116
74
"locked": {
117
-
"lastModified": 1725432240,
118
-
"narHash": "sha256-+yj+xgsfZaErbfYM3T+QvEE2hU7UuE+Jf0fJCJ8uPS0=",
75
+
"lastModified": 1756266583,
76
+
"narHash": "sha256-cr748nSmpfvnhqSXPiCfUPxRz2FJnvf/RjJGvFfaCsM=",
119
77
"owner": "NixOS",
120
78
"repo": "nixpkgs",
121
-
"rev": "ad416d066ca1222956472ab7d0555a6946746a80",
79
+
"rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2",
122
80
"type": "github"
123
81
},
124
82
"original": {
···
133
91
"advisory-db": "advisory-db",
134
92
"crane": "crane",
135
93
"fenix": "fenix",
136
-
"flake-utils": "flake-utils_2",
94
+
"flake-utils": "flake-utils",
137
95
"nixpkgs": "nixpkgs"
138
96
}
139
97
},
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
98
"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
99
"locked": {
182
100
"lastModified": 1681028828,
183
101
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+4
-3
flake.nix
+4
-3
flake.nix
···
32
32
33
33
inherit (pkgs) lib;
34
34
35
-
craneLib = crane.lib.${system};
35
+
craneLib = crane.mkLib pkgs;
36
36
37
37
protoFilter = path: _type: builtins.match ".*proto$" path != null;
38
38
protoOrCargo = path: type:
···
46
46
# Common arguments can be set here to avoid repeating them later
47
47
commonArgs = {
48
48
inherit src;
49
-
49
+
50
50
pname = "tunein";
51
-
version = "0.3.1";
51
+
version = "0.4.1";
52
52
53
53
buildInputs = [
54
54
# Add additional build inputs here
···
59
59
pkgs.perl
60
60
pkgs.protobuf
61
61
pkgs.alsa-lib.dev
62
+
pkgs.dbus
62
63
] ++ lib.optionals pkgs.stdenv.isDarwin [
63
64
# Additional darwin specific inputs can be set here
64
65
pkgs.libiconv
+293
-34
src/app.rs
+293
-34
src/app.rs
···
3
3
prelude::*,
4
4
widgets::{block::*, *},
5
5
};
6
+
use souvlaki::MediaControlEvent;
6
7
use std::{
7
8
io,
8
9
ops::Range,
···
11
12
time::{Duration, Instant},
12
13
};
13
14
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
15
+
use tunein_cli::os_media_controls::{self, OsMediaControls};
14
16
15
17
use crate::{
16
18
extract::get_currently_playing,
···
76
78
self.is_muted = !self.is_muted;
77
79
}
78
80
81
+
/// Set the volume to the given volume ratio.
82
+
///
83
+
/// `1.0` is 100% volume.
84
+
pub const fn set_volume_ratio(&mut self, volume: f32) {
85
+
self.raw_volume_percent = volume * 100.0;
86
+
self.raw_volume_percent = self.raw_volume_percent.max(0.0);
87
+
}
88
+
79
89
/// Change the volume by the given step percent.
80
90
///
81
91
/// To increase the volume, use a positive step. To decrease the
···
135
145
spectroscope: Spectroscope,
136
146
mode: CurrentDisplayMode,
137
147
frame_rx: Receiver<minimp3::Frame>,
148
+
/// [`OsMediaControls`].
149
+
os_media_controls: Option<OsMediaControls>,
150
+
/// Poll for events every specified [`Duration`].
151
+
///
152
+
/// Allows user to decide the trade off between computational
153
+
/// resource comsumption, animation smoothness and how responsive
154
+
/// the application. Smaller durations lead to more resource
155
+
/// consumption but smoother animations and better responsiveness.
156
+
poll_events_every: Duration,
157
+
/// [`Self::poll_events_every`] but when player is paused.
158
+
///
159
+
/// This should generally be larger than
160
+
/// [`Self::poll_events_every`].
161
+
poll_events_every_while_paused: Duration,
138
162
}
139
163
140
164
impl App {
···
143
167
source: &crate::cfg::SourceOptions,
144
168
frame_rx: Receiver<minimp3::Frame>,
145
169
mode: CurrentDisplayMode,
170
+
os_media_controls: Option<OsMediaControls>,
171
+
poll_events_every: Duration,
172
+
poll_events_every_while_paused: Duration,
146
173
) -> Self {
147
174
let graph = GraphConfig {
148
175
axis_color: Color::DarkGray,
···
175
202
mode,
176
203
channels: source.channels as u8,
177
204
frame_rx,
205
+
os_media_controls,
206
+
poll_events_every,
207
+
poll_events_every_while_paused,
178
208
}
179
209
}
180
210
}
···
307
337
id: &str,
308
338
) {
309
339
let new_state = cmd_rx.recv().await.unwrap();
310
-
let new_state = Arc::new(Mutex::new(new_state));
311
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));
312
366
let id = id.to_string();
313
367
let new_state_clone = new_state.clone();
314
368
315
-
thread::spawn(move || loop {
369
+
// Background thread to update now_playing
370
+
thread::spawn(move || {
316
371
let rt = tokio::runtime::Runtime::new().unwrap();
317
372
rt.block_on(async {
318
-
let mut new_state = new_state_clone.lock().unwrap();
319
-
// Get current playing if available, otherwise use state's value
320
-
new_state.now_playing = get_currently_playing(&id).await.unwrap_or_default();
321
-
drop(new_state);
322
-
std::thread::sleep(Duration::from_millis(10000));
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
+
}
323
383
});
324
384
});
325
385
326
386
let mut fps = 0;
327
387
let mut framerate = 0;
328
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
329
392
330
393
loop {
331
-
let channels = (!self.graph.pause)
332
-
.then(|| self.frame_rx.recv().unwrap())
333
-
.map(|audio_frame| {
334
-
stream_to_matrix(audio_frame.data.iter().cloned(), audio_frame.channels, 1.)
335
-
});
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(
402
+
audio_frame.data.iter().cloned(),
403
+
audio_frame.channels,
404
+
1.,
405
+
))
406
+
};
336
407
337
408
fps += 1;
338
409
···
379
450
size.y += 8;
380
451
}
381
452
let chart = Chart::new(datasets.iter().map(|x| x.into()).collect())
382
-
.x_axis(current_display.axis(&self.graph, Dimension::X)) // TODO allow to have axis sometimes?
453
+
.x_axis(current_display.axis(&self.graph, Dimension::X))
383
454
.y_axis(current_display.axis(&self.graph, Dimension::Y));
384
455
f.render_widget(chart, size)
385
456
}
386
457
})
387
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
+
}
388
480
}
389
481
390
-
while event::poll(Duration::from_millis(0)).unwrap() {
482
+
while let Some(event) = self
483
+
.os_media_controls
484
+
.as_mut()
485
+
.and_then(|os_media_controls| os_media_controls.try_recv_os_event())
486
+
{
487
+
if self.process_os_media_control_event(event, &new_state, &mut sink_cmd_tx) {
488
+
return;
489
+
}
490
+
}
491
+
492
+
let timeout_duration = if self.graph.pause {
493
+
self.poll_events_every_while_paused
494
+
} else {
495
+
self.poll_events_every
496
+
};
497
+
498
+
while event::poll(timeout_duration).unwrap() {
391
499
// process all enqueued events
392
500
let event = event::read().unwrap();
393
501
···
403
511
}
404
512
}
405
513
}
406
-
407
514
fn current_display_mut(&mut self) -> Option<&mut dyn DisplayMode> {
408
515
match self.mode {
409
516
CurrentDisplayMode::Oscilloscope => {
···
452
559
KeyCode::Up => {
453
560
// inverted to act as zoom
454
561
update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..10.0);
455
-
raise_volume(&state, sink_cmd_tx);
562
+
raise_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx);
456
563
}
457
564
KeyCode::Down => {
458
565
// inverted to act as zoom
459
566
update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..10.0);
460
-
lower_volume(&state, sink_cmd_tx);
567
+
lower_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx);
461
568
}
462
569
KeyCode::Right => update_value_i(
463
570
&mut self.graph.samples,
···
474
581
0..self.graph.width * 2,
475
582
),
476
583
KeyCode::Char('q') => quit = true,
477
-
KeyCode::Char(' ') => toggle_play_pause(&mut self.graph, sink_cmd_tx),
584
+
KeyCode::Char(' ') => toggle_play_pause(
585
+
&mut self.graph,
586
+
self.os_media_controls.as_mut(),
587
+
sink_cmd_tx,
588
+
),
478
589
KeyCode::Char('s') => self.graph.scatter = !self.graph.scatter,
479
590
KeyCode::Char('h') => self.graph.show_ui = !self.graph.show_ui,
480
591
KeyCode::Char('r') => self.graph.references = !self.graph.references,
481
-
KeyCode::Char('m') => mute_volume(&state, sink_cmd_tx),
592
+
KeyCode::Char('m') => {
593
+
mute_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
594
+
}
482
595
KeyCode::Esc => {
483
596
self.graph.samples = self.graph.width;
484
597
self.graph.scale = 1.;
···
504
617
}
505
618
}
506
619
KeyCode::Media(media_key_code) => match media_key_code {
507
-
MediaKeyCode::Play => play(&mut self.graph, sink_cmd_tx),
508
-
MediaKeyCode::Pause => pause(&mut self.graph, sink_cmd_tx),
509
-
MediaKeyCode::PlayPause => toggle_play_pause(&mut self.graph, sink_cmd_tx),
620
+
MediaKeyCode::Play => play(
621
+
&mut self.graph,
622
+
self.os_media_controls.as_mut(),
623
+
sink_cmd_tx,
624
+
),
625
+
MediaKeyCode::Pause => pause(
626
+
&mut self.graph,
627
+
self.os_media_controls.as_mut(),
628
+
sink_cmd_tx,
629
+
),
630
+
MediaKeyCode::PlayPause => toggle_play_pause(
631
+
&mut self.graph,
632
+
self.os_media_controls.as_mut(),
633
+
sink_cmd_tx,
634
+
),
510
635
MediaKeyCode::Stop => {
511
636
quit = true;
512
637
}
513
-
MediaKeyCode::LowerVolume => lower_volume(&state, sink_cmd_tx),
514
-
MediaKeyCode::RaiseVolume => raise_volume(&state, sink_cmd_tx),
515
-
MediaKeyCode::MuteVolume => mute_volume(&state, sink_cmd_tx),
638
+
MediaKeyCode::LowerVolume => {
639
+
lower_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
640
+
}
641
+
MediaKeyCode::RaiseVolume => {
642
+
raise_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
643
+
}
644
+
MediaKeyCode::MuteVolume => {
645
+
mute_volume(&state, self.os_media_controls.as_mut(), sink_cmd_tx)
646
+
}
516
647
MediaKeyCode::TrackNext
517
648
| MediaKeyCode::TrackPrevious
518
649
| MediaKeyCode::Reverse
···
526
657
527
658
Ok(quit)
528
659
}
660
+
661
+
/// Process OS media control event.
662
+
///
663
+
/// Returns [`true`] if application should quit.
664
+
fn process_os_media_control_event(
665
+
&mut self,
666
+
event: MediaControlEvent,
667
+
state: &Mutex<State>,
668
+
sink_cmd_tx: &mut UnboundedSender<SinkCommand>,
669
+
) -> bool {
670
+
let mut quit = false;
671
+
672
+
match event {
673
+
MediaControlEvent::Play => {
674
+
play(
675
+
&mut self.graph,
676
+
self.os_media_controls.as_mut(),
677
+
sink_cmd_tx,
678
+
);
679
+
}
680
+
MediaControlEvent::Pause => {
681
+
pause(
682
+
&mut self.graph,
683
+
self.os_media_controls.as_mut(),
684
+
sink_cmd_tx,
685
+
);
686
+
}
687
+
MediaControlEvent::Toggle => {
688
+
toggle_play_pause(
689
+
&mut self.graph,
690
+
self.os_media_controls.as_mut(),
691
+
sink_cmd_tx,
692
+
);
693
+
}
694
+
MediaControlEvent::Stop | MediaControlEvent::Quit => {
695
+
quit = true;
696
+
}
697
+
MediaControlEvent::SetVolume(volume) => {
698
+
set_volume_ratio(
699
+
volume as f32,
700
+
state,
701
+
self.os_media_controls.as_mut(),
702
+
sink_cmd_tx,
703
+
);
704
+
}
705
+
MediaControlEvent::Next
706
+
| MediaControlEvent::Previous
707
+
| MediaControlEvent::Seek(_)
708
+
| MediaControlEvent::SeekBy(_, _)
709
+
| MediaControlEvent::SetPosition(_)
710
+
| MediaControlEvent::OpenUri(_)
711
+
| MediaControlEvent::Raise => {}
712
+
}
713
+
714
+
quit
715
+
}
529
716
}
530
717
531
718
pub fn update_value_f(val: &mut f64, base: f64, magnitude: f64, range: Range<f64>) {
···
589
776
}
590
777
591
778
/// Play music.
592
-
fn play(graph: &mut GraphConfig, sink_cmd_tx: &UnboundedSender<SinkCommand>) {
779
+
fn play(
780
+
graph: &mut GraphConfig,
781
+
os_media_controls: Option<&mut OsMediaControls>,
782
+
sink_cmd_tx: &UnboundedSender<SinkCommand>,
783
+
) {
593
784
graph.pause = false;
785
+
send_os_media_controls_command(os_media_controls, os_media_controls::Command::Play);
594
786
sink_cmd_tx
595
787
.send(SinkCommand::Play)
596
788
.expect("receiver never dropped");
597
789
}
598
790
599
791
/// Pause music.
600
-
fn pause(graph: &mut GraphConfig, sink_cmd_tx: &UnboundedSender<SinkCommand>) {
792
+
fn pause(
793
+
graph: &mut GraphConfig,
794
+
os_media_controls: Option<&mut OsMediaControls>,
795
+
sink_cmd_tx: &UnboundedSender<SinkCommand>,
796
+
) {
601
797
graph.pause = true;
798
+
send_os_media_controls_command(os_media_controls, os_media_controls::Command::Pause);
602
799
sink_cmd_tx
603
800
.send(SinkCommand::Pause)
604
801
.expect("receiver never dropped");
605
802
}
606
803
607
804
/// Toggle between play and pause.
608
-
fn toggle_play_pause(graph: &mut GraphConfig, sink_cmd_tx: &UnboundedSender<SinkCommand>) {
805
+
fn toggle_play_pause(
806
+
graph: &mut GraphConfig,
807
+
os_media_controls: Option<&mut OsMediaControls>,
808
+
sink_cmd_tx: &UnboundedSender<SinkCommand>,
809
+
) {
609
810
graph.pause = !graph.pause;
610
-
let sink_cmd = if graph.pause {
611
-
SinkCommand::Pause
811
+
let (sink_cmd, os_media_controls_command) = if graph.pause {
812
+
(SinkCommand::Pause, os_media_controls::Command::Pause)
612
813
} else {
613
-
SinkCommand::Play
814
+
(SinkCommand::Play, os_media_controls::Command::Play)
614
815
};
816
+
send_os_media_controls_command(os_media_controls, os_media_controls_command);
615
817
sink_cmd_tx.send(sink_cmd).expect("receiver never dropped");
616
818
}
617
819
618
820
/// Lower the volume.
619
-
fn lower_volume(state: &Arc<Mutex<State>>, sink_cmd_tx: &UnboundedSender<SinkCommand>) {
821
+
fn lower_volume(
822
+
state: &Mutex<State>,
823
+
os_media_controls: Option<&mut OsMediaControls>,
824
+
sink_cmd_tx: &UnboundedSender<SinkCommand>,
825
+
) {
620
826
let mut state = state.lock().unwrap();
621
827
state.volume.change_volume(-1.0);
828
+
send_os_media_controls_command(
829
+
os_media_controls,
830
+
os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
831
+
);
622
832
sink_cmd_tx
623
833
.send(SinkCommand::SetVolume(state.volume.volume_ratio()))
624
834
.expect("receiver never dropped");
625
835
}
626
836
627
837
/// Raise the volume.
628
-
fn raise_volume(state: &Arc<Mutex<State>>, sink_cmd_tx: &UnboundedSender<SinkCommand>) {
838
+
fn raise_volume(
839
+
state: &Mutex<State>,
840
+
os_media_controls: Option<&mut OsMediaControls>,
841
+
sink_cmd_tx: &UnboundedSender<SinkCommand>,
842
+
) {
629
843
let mut state = state.lock().unwrap();
630
844
state.volume.change_volume(1.0);
845
+
send_os_media_controls_command(
846
+
os_media_controls,
847
+
os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
848
+
);
631
849
sink_cmd_tx
632
850
.send(SinkCommand::SetVolume(state.volume.volume_ratio()))
633
851
.expect("receiver never dropped");
634
852
}
635
853
636
854
/// Mute the volume.
637
-
fn mute_volume(state: &Arc<Mutex<State>>, sink_cmd_tx: &UnboundedSender<SinkCommand>) {
855
+
fn mute_volume(
856
+
state: &Mutex<State>,
857
+
os_media_controls: Option<&mut OsMediaControls>,
858
+
sink_cmd_tx: &UnboundedSender<SinkCommand>,
859
+
) {
638
860
let mut state = state.lock().unwrap();
639
861
state.volume.toggle_mute();
862
+
send_os_media_controls_command(
863
+
os_media_controls,
864
+
os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
865
+
);
640
866
sink_cmd_tx
641
867
.send(SinkCommand::SetVolume(state.volume.volume_ratio()))
642
868
.expect("receiver never dropped");
643
869
}
870
+
871
+
/// Set the volume to the given volume ratio.
872
+
fn set_volume_ratio(
873
+
volume_ratio: f32,
874
+
state: &Mutex<State>,
875
+
os_media_controls: Option<&mut OsMediaControls>,
876
+
sink_cmd_tx: &UnboundedSender<SinkCommand>,
877
+
) {
878
+
let mut state = state.lock().unwrap();
879
+
state.volume.set_volume_ratio(volume_ratio);
880
+
send_os_media_controls_command(
881
+
os_media_controls,
882
+
os_media_controls::Command::SetVolume(state.volume.volume_ratio() as f64),
883
+
);
884
+
sink_cmd_tx
885
+
.send(SinkCommand::SetVolume(state.volume.volume_ratio()))
886
+
.expect("receiver never dropped");
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
+
) {
894
+
if let Some(os_media_controls) = os_media_controls {
895
+
let _ = os_media_controls.send_to_os(command).inspect_err(|err| {
896
+
eprintln!(
897
+
"error: failed to send command to OS media controls due to `{}`",
898
+
err
899
+
);
900
+
});
901
+
}
902
+
}
+223
src/audio.rs
+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
+6
-5
src/decoder.rs
···
11
11
decoder: Decoder<R>,
12
12
current_frame: Frame,
13
13
current_frame_offset: usize,
14
-
tx: Sender<Frame>,
14
+
tx: Option<Sender<Frame>>,
15
15
}
16
16
17
17
impl<R> Mp3Decoder<R>
18
18
where
19
19
R: Read,
20
20
{
21
-
pub fn new(mut data: R, tx: Sender<Frame>) -> Result<Self, R> {
21
+
pub fn new(mut data: R, tx: Option<Sender<Frame>>) -> Result<Self, R> {
22
22
if !is_mp3(data.by_ref()) {
23
23
return Err(data);
24
24
}
···
70
70
if self.current_frame_offset == self.current_frame.data.len() {
71
71
match self.decoder.next_frame() {
72
72
Ok(frame) => {
73
-
match self.tx.send(frame.clone()) {
74
-
Ok(_) => {}
75
-
Err(_) => return None,
73
+
if let Some(tx) = &self.tx {
74
+
if tx.send(frame.clone()).is_err() {
75
+
return None;
76
+
}
76
77
}
77
78
self.current_frame = frame
78
79
}
+6
-1
src/extract.rs
+6
-1
src/extract.rs
+108
src/favorites.rs
+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
+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
+
}
+42
-10
src/main.rs
+42
-10
src/main.rs
···
1
+
use std::time::Duration;
2
+
1
3
use anyhow::Error;
2
4
use app::CurrentDisplayMode;
3
-
use clap::{arg, Command};
5
+
use clap::{arg, builder::ValueParser, Command};
4
6
5
7
mod app;
8
+
mod audio;
6
9
mod browse;
7
10
mod cfg;
8
11
mod decoder;
9
12
mod extract;
13
+
mod favorites;
10
14
mod input;
15
+
mod interactive;
11
16
mod music;
12
17
mod play;
13
18
mod player;
···
37
42
.arg(
38
43
arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein")
39
44
)
40
-
.subcommand_required(true)
41
45
.subcommand(
42
46
Command::new("search")
43
47
.about("Search for a radio station")
···
48
52
.about("Play a radio station")
49
53
.arg(arg!(<station> "The station to play"))
50
54
.arg(arg!(--volume "Set the initial volume (as a percent)").default_value("100"))
51
-
.arg(clap::Arg::new("display-mode").long("display-mode").help("Set the display mode to start with").default_value("Spectroscope")),
55
+
.arg(clap::Arg::new("display-mode").long("display-mode").help("Set the display mode to start with").default_value("Spectroscope"))
56
+
.arg(clap::Arg::new("enable-os-media-controls").long("enable-os-media-controls").help("Should enable OS media controls?").default_value("true").value_parser(ValueParser::bool()))
57
+
.arg(clap::Arg::new("poll-events-every").long("poll-events-every").help("Poll for events every specified milliseconds.").default_value("16"))
58
+
.arg(clap::Arg::new("poll-events-every-while-paused").long("poll-events-every-while-paused").help("Poll for events every specified milliseconds while player is paused.").default_value("100")),
52
59
)
53
60
.subcommand(
54
61
Command::new("browse")
···
83
90
#[tokio::main]
84
91
async fn main() -> Result<(), Error> {
85
92
let matches = cli().get_matches();
93
+
let provider = matches.value_of("provider").unwrap().to_string();
86
94
87
95
match matches.subcommand() {
88
96
Some(("search", args)) => {
89
97
let query = args.value_of("query").unwrap();
90
-
let provider = matches.value_of("provider").unwrap();
91
-
search::exec(query, provider).await?;
98
+
search::exec(query, provider.as_str()).await?;
92
99
}
93
100
Some(("play", args)) => {
94
101
let station = args.value_of("station").unwrap();
95
-
let provider = matches.value_of("provider").unwrap();
96
102
let volume = args.value_of("volume").unwrap().parse::<f32>().unwrap();
97
103
let display_mode = args
98
104
.value_of("display-mode")
99
105
.unwrap()
100
106
.parse::<CurrentDisplayMode>()
101
107
.unwrap();
102
-
play::exec(station, provider, volume, display_mode).await?;
108
+
let enable_os_media_controls = args.get_one("enable-os-media-controls").unwrap();
109
+
let poll_events_every =
110
+
Duration::from_millis(args.value_of("poll-events-every").unwrap().parse().unwrap());
111
+
let poll_events_every_while_paused = Duration::from_millis(
112
+
args.value_of("poll-events-every-while-paused")
113
+
.unwrap()
114
+
.parse()
115
+
.unwrap(),
116
+
);
117
+
play::exec(
118
+
station,
119
+
provider.as_str(),
120
+
volume,
121
+
display_mode,
122
+
*enable_os_media_controls,
123
+
poll_events_every,
124
+
poll_events_every_while_paused,
125
+
)
126
+
.await?;
103
127
}
104
128
Some(("browse", args)) => {
105
129
let category = args.value_of("category");
106
130
let offset = args.value_of("offset").unwrap();
107
131
let limit = args.value_of("limit").unwrap();
108
-
let provider = matches.value_of("provider").unwrap();
109
132
browse::exec(
110
133
category,
111
134
offset.parse::<u32>()?,
112
135
limit.parse::<u32>()?,
113
-
provider,
136
+
provider.as_str(),
114
137
)
115
138
.await?;
116
139
}
···
128
151
std::process::exit(1);
129
152
}
130
153
},
131
-
_ => unreachable!(),
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
+
}
132
164
}
133
165
134
166
Ok(())
+30
-4
src/play.rs
+30
-4
src/play.rs
···
1
-
use std::{thread, time::Duration};
1
+
use std::{process, thread, time::Duration};
2
2
3
3
use anyhow::Error;
4
4
use hyper::header::HeaderValue;
5
+
use tunein_cli::os_media_controls::OsMediaControls;
5
6
6
7
use crate::{
7
8
app::{App, CurrentDisplayMode, State, Volume},
···
16
17
provider: &str,
17
18
volume: f32,
18
19
display_mode: CurrentDisplayMode,
20
+
enable_os_media_controls: bool,
21
+
poll_events_every: Duration,
22
+
poll_events_every_while_paused: Duration,
19
23
) -> Result<(), Error> {
20
24
let _provider = provider;
21
25
let provider: Box<dyn Provider> = match provider {
···
57
61
tune: None,
58
62
};
59
63
60
-
let mut app = App::new(&ui, &opts, frame_rx, display_mode);
64
+
let os_media_controls = if enable_os_media_controls {
65
+
OsMediaControls::new()
66
+
.inspect_err(|err| {
67
+
eprintln!(
68
+
"error: failed to initialize os media controls due to `{}`",
69
+
err
70
+
);
71
+
})
72
+
.ok()
73
+
} else {
74
+
None
75
+
};
76
+
77
+
let mut app = App::new(
78
+
&ui,
79
+
&opts,
80
+
frame_rx,
81
+
display_mode,
82
+
os_media_controls,
83
+
poll_events_every,
84
+
poll_events_every_while_paused,
85
+
);
61
86
let station_name = station.name.clone();
62
87
63
88
thread::spawn(move || {
···
118
143
let (_stream, handle) = rodio::OutputStream::try_default().unwrap();
119
144
let sink = rodio::Sink::try_new(&handle).unwrap();
120
145
sink.set_volume(volume.volume_ratio());
121
-
let decoder = Mp3Decoder::new(response, frame_tx).unwrap();
146
+
let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap();
122
147
sink.append(decoder);
123
148
124
149
loop {
···
142
167
let mut terminal = tui::init()?;
143
168
app.run(&mut terminal, cmd_rx, sink_cmd_tx, &id).await;
144
169
tui::restore()?;
145
-
Ok(())
170
+
171
+
process::exit(0);
146
172
}
147
173
148
174
/// Command for a sink.
+2
-2
src/player.rs
+2
-2
src/player.rs
···
61
61
let sink = self.sink.clone();
62
62
63
63
thread::spawn(move || {
64
-
let (frame_tx, frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>();
64
+
let (frame_tx, _frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>();
65
65
let client = reqwest::blocking::Client::new();
66
66
67
67
let response = client.get(url.clone()).send().unwrap();
···
80
80
}
81
81
None => response,
82
82
};
83
-
let decoder = Mp3Decoder::new(response, frame_tx).unwrap();
83
+
let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap();
84
84
85
85
{
86
86
let sink = sink.lock().unwrap();
+6
-1
src/provider/tunein.rs
+6
-1
src/provider/tunein.rs
···
55
55
None => Ok(None),
56
56
}
57
57
}
58
-
_ => Ok(Some(Station::from(stations[0].clone()))),
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
+
}
59
64
}
60
65
}
61
66
+2
-1
src/visualization/mod.rs
+2
-1
src/visualization/mod.rs
···
20
20
pub struct GraphConfig {
21
21
pub pause: bool,
22
22
pub samples: u32,
23
+
#[allow(dead_code)]
23
24
pub sampling_rate: u32,
24
25
pub scale: f64,
25
26
pub width: u32,
···
47
48
fn from_args(args: &crate::cfg::SourceOptions) -> Self
48
49
where
49
50
Self: Sized;
50
-
fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this
51
+
fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_>; // TODO simplify this
51
52
fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>;
52
53
fn mode_str(&self) -> &'static str;
53
54
+1
-1
src/visualization/oscilloscope.rs
+1
-1
src/visualization/oscilloscope.rs
···
55
55
}
56
56
}
57
57
58
-
fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis {
58
+
fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> {
59
59
let (name, bounds) = match dimension {
60
60
Dimension::X => ("time -", [0.0, cfg.samples as f64]),
61
61
Dimension::Y => ("| amplitude", [-cfg.scale, cfg.scale]),
+1
-1
src/visualization/spectroscope.rs
+1
-1
src/visualization/spectroscope.rs
+1
-1
src/visualization/vectorscope.rs
+1
-1
src/visualization/vectorscope.rs
···
28
28
"live".into()
29
29
}
30
30
31
-
fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis {
31
+
fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> {
32
32
let (name, bounds) = match dimension {
33
33
Dimension::X => ("left -", [-cfg.scale, cfg.scale]),
34
34
Dimension::Y => ("| right", [-cfg.scale, cfg.scale]),