Skip to content

Commit c1931c5

Browse files
committed
Another attempt at AirPlay support
1 parent 3d77127 commit c1931c5

14 files changed

Lines changed: 535 additions & 4 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Docker Image CI
2+
3+
on:
4+
push:
5+
#tags:
6+
#- '*'
7+
# To have GitHub Actions deploy a new version, create a new tag
8+
# git tag -a yyyy.m.d -m yyyy.m.d
9+
# Then push those tags
10+
# git push --tags
11+
# Then a new version will be deployed on the self-hosted runner.
12+
13+
jobs:
14+
rpi4:
15+
runs-on: [self-hosted, "${{ matrix.runner}}" ]
16+
strategy:
17+
matrix:
18+
include:
19+
- runner: rpi4
20+
airplay_name: "Kitchen HomeSpeaker"
21+
- runner: rpi42
22+
airplay_name: "Upstairs HomeSpeaker"
23+
24+
steps:
25+
- uses: actions/checkout@v3
26+
27+
- name: Docker compose pull
28+
run: docker-compose pull
29+
30+
- name: Docker compose down
31+
run: docker-compose down
32+
33+
- name: Docker compose up
34+
env:
35+
COMPOSE_DOCKER_CLI_BUILD: 1
36+
DOCKER_BUILDKIT: 1
37+
GOVEE_API_KEY: ${{ secrets.GOVEE_API_KEY }}
38+
AIRPLAY_NAME: "${{ matrix.airplay_name }}"
39+
run: docker-compose up -d --build --remove-orphans
40+
41+
- name: Wait for services to be ready
42+
run: sleep 25
43+
44+
- name: Refresh browser on touchscreen
45+
run: |
46+
export DISPLAY=:0
47+
xdotool key F5
48+
continue-on-error: true

.github/workflows/docker-image.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ jobs:
1515
runs-on: [self-hosted, "${{ matrix.runner}}" ]
1616
strategy:
1717
matrix:
18-
runner: [rpi4, rpi42]
18+
include:
19+
- runner: rpi4
20+
airplay_name: "Kitchen HomeSpeaker"
21+
- runner: rpi42
22+
airplay_name: "Upstairs HomeSpeaker"
1923

2024
steps:
2125
- uses: actions/checkout@v3
@@ -31,6 +35,7 @@ jobs:
3135
COMPOSE_DOCKER_CLI_BUILD: 1
3236
DOCKER_BUILDKIT: 1
3337
GOVEE_API_KEY: ${{ secrets.GOVEE_API_KEY }}
38+
AIRPLAY_NAME: "${{ matrix.airplay_name }}"
3439
run: docker-compose up -d --build --remove-orphans
3540

3641
- name: Wait for services to be ready
@@ -39,5 +44,6 @@ jobs:
3944
- name: Refresh browser on touchscreen
4045
run: |
4146
export DISPLAY=:0
42-
xdotool key F5
43-
continue-on-error: true
47+
xdotool key F5
48+
continue-on-error:
49+
true

HomeSpeaker.Server2/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
builder.Services.AddGrpc();
3434
builder.Services.AddHostedService<MigrationApplier>();
3535
builder.Services.AddHostedService<DailyAnchorWorker>();
36+
builder.Services.AddHostedService<AirPlayReceiverService>();
3637
builder.Services.AddScoped<PlaylistService>();
3738
builder.Services.AddScoped<AnchorService>();
3839
builder.Services.AddDbContext<MusicContext>(options => options.UseSqlite(builder.Configuration["SqliteConnectionString"]));
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.IO.Pipes;
2+
using HomeSpeaker.Server;
3+
using System.Diagnostics;
4+
5+
namespace HomeSpeaker.Server2.Services;
6+
7+
public class AirPlayReceiverService : BackgroundService
8+
{
9+
private readonly ILogger<AirPlayReceiverService> logger;
10+
private readonly IMusicPlayer musicPlayer; private const string MetadataPipePath = "/tmp/airplay-shared/metadata";
11+
private const string AirPlayStatePath = "/tmp/airplay-shared/state";
12+
private const string AirPlayLogPath = "/tmp/airplay-shared/log";
13+
private bool airplayActive = false;
14+
15+
public AirPlayReceiverService(ILogger<AirPlayReceiverService> logger, IMusicPlayer musicPlayer)
16+
{
17+
this.logger = logger;
18+
this.musicPlayer = musicPlayer;
19+
}
20+
21+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
22+
{
23+
logger.LogInformation("AirPlay Receiver Service starting...");
24+
25+
// Monitor for AirPlay session events via shared state file
26+
_ = Task.Run(() => MonitorAirPlayEvents(stoppingToken), stoppingToken);
27+
28+
// Monitor for metadata (song info, artwork, etc.)
29+
_ = Task.Run(() => MonitorAirPlayMetadata(stoppingToken), stoppingToken);
30+
31+
// Keep service running
32+
await Task.Delay(Timeout.Infinite, stoppingToken);
33+
}
34+
35+
private async Task MonitorAirPlayEvents(CancellationToken cancellationToken)
36+
{
37+
// Monitor the shared state file written by ShairportSync scripts
38+
while (!cancellationToken.IsCancellationRequested)
39+
{
40+
try
41+
{
42+
if (File.Exists(AirPlayStatePath))
43+
{
44+
var stateContent = await File.ReadAllTextAsync(AirPlayStatePath, cancellationToken);
45+
bool currentlyActive = stateContent.Trim().Equals("ACTIVE", StringComparison.OrdinalIgnoreCase);
46+
47+
if (currentlyActive && !airplayActive)
48+
{
49+
logger.LogInformation("AirPlay session started - pausing local playback");
50+
musicPlayer.Stop(); // Pause local music when AirPlay starts
51+
airplayActive = true;
52+
}
53+
else if (!currentlyActive && airplayActive)
54+
{
55+
logger.LogInformation("AirPlay session ended - can resume local playback");
56+
airplayActive = false;
57+
// Optionally auto-resume: musicPlayer.ResumePlay();
58+
}
59+
}
60+
61+
await Task.Delay(1000, cancellationToken);
62+
}
63+
catch (Exception ex)
64+
{
65+
logger.LogError(ex, "Error monitoring AirPlay state file");
66+
await Task.Delay(5000, cancellationToken);
67+
}
68+
}
69+
}
70+
71+
private async Task MonitorAirPlayMetadata(CancellationToken cancellationToken)
72+
{
73+
while (!cancellationToken.IsCancellationRequested)
74+
{
75+
try
76+
{
77+
if (File.Exists(MetadataPipePath))
78+
{
79+
using var reader = new StreamReader(MetadataPipePath);
80+
var metadata = await reader.ReadToEndAsync();
81+
82+
if (!string.IsNullOrEmpty(metadata))
83+
{
84+
logger.LogInformation("AirPlay metadata: {metadata}", metadata);
85+
// Parse metadata and update UI if needed
86+
// Could send events through your existing SendEvent mechanism
87+
}
88+
}
89+
90+
await Task.Delay(500, cancellationToken);
91+
}
92+
catch (Exception ex)
93+
{
94+
logger.LogError(ex, "Error reading AirPlay metadata");
95+
await Task.Delay(2000, cancellationToken);
96+
}
97+
}
98+
}
99+
}

asound-alternative1.conf

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Alternative 1: If "Headphones" is actually card 1
2+
pcm.!default {
3+
type dmix
4+
ipc_key 1024
5+
slave {
6+
pcm "hw:1,0" # Try card 1 if Headphones is card 1
7+
period_time 0
8+
period_size 1024
9+
buffer_size 4096
10+
rate 44100
11+
}
12+
bindings {
13+
0 0
14+
1 1
15+
}
16+
}
17+
18+
ctl.!default {
19+
type hw
20+
card 1
21+
}

asound-alternative2.conf

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Alternative 2: Use card 0 if that's what amixer PCM,0 actually targets
2+
pcm.!default {
3+
type dmix
4+
ipc_key 1024
5+
slave {
6+
pcm "hw:0,0" # Back to card 0 if that's what amixer uses
7+
period_time 0
8+
period_size 1024
9+
buffer_size 4096
10+
rate 44100
11+
}
12+
bindings {
13+
0 0
14+
1 1
15+
}
16+
}
17+
18+
ctl.!default {
19+
type hw
20+
card 0
21+
}

asound.conf

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ALSA configuration for audio mixing
2+
# Place this in /etc/asound.conf on the host system
3+
4+
# Use Headphones card (matching ALSA_CARD=Headphones)
5+
pcm.!default {
6+
type dmix
7+
ipc_key 1024
8+
slave {
9+
pcm "hw:Headphones" # Use the Headphones card name from your environment
10+
period_time 0
11+
period_size 1024
12+
buffer_size 4096
13+
rate 44100
14+
}
15+
bindings {
16+
0 0
17+
1 1
18+
}
19+
}
20+
21+
ctl.!default {
22+
type hw
23+
card "Headphones" # Use the Headphones card name
24+
}

docker-compose-final.yml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
version: '3.4'
2+
services:
3+
homespeaker.server2:
4+
container_name: homespeaker
5+
image: homespeakerserver2
6+
build:
7+
context: .
8+
dockerfile: HomeSpeaker.Server2/Dockerfile
9+
restart: unless-stopped
10+
devices:
11+
- /dev/snd:/dev/snd
12+
ports:
13+
- 80:80
14+
- 443:443
15+
volumes:
16+
- "/home/piuser/music:/music"
17+
- "/home/piuser/cert:/certs"
18+
- "/sys/class/backlight/10-0045:/sys/class/backlight/10-0045"
19+
- airplay-shared:/tmp/airplay-shared # Shared volume for AirPlay state
20+
- /run/user/1000/pulse:/run/user/1000/pulse:rw
21+
depends_on:
22+
- aspire
23+
environment:
24+
- MediaFolder=/music
25+
- ALSA_CARD=Headphones
26+
- SqliteConnectionString=Data Source=/music/HomeSpeaker.db
27+
- ASPNETCORE_URLS=https://+443;http://+:80
28+
- ASPNETCORE_Kestrel__Certificates__Default__Path=/certs/certificate.pfx
29+
- FFMpegLocation=/usr/bin/ffmpeg
30+
- OTEL_EXPORTER_OTLP_PROTOCOL=grpc
31+
- OTEL_EXPORTER_OTLP_ENDPOINT=http://aspire:18889
32+
- OTEL_SERVICE_NAME=HomeSpeaker
33+
- NIGHTSCOUT_URL=https://janedoe.azurewebsites.net
34+
- Temperature__ApiKey=${GOVEE_API_KEY}
35+
- PULSE_SERVER=unix:/run/user/1000/pulse/native
36+
networks:
37+
speakernet:
38+
39+
aspire:
40+
container_name: aspire
41+
image: mcr.microsoft.com/dotnet/aspire-dashboard:9.0
42+
environment:
43+
- DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true
44+
ports:
45+
- 18888:18888 # web
46+
- 4317:18889 # OTLP
47+
networks:
48+
speakernet:
49+
50+
airplay-receiver:
51+
image: mikebrady/shairport-sync:latest
52+
container_name: homespeaker-airplay
53+
restart: unless-stopped
54+
network_mode: host
55+
volumes:
56+
- ./shairport-sync.conf:/etc/shairport-sync.conf:ro
57+
- airplay-shared:/tmp/airplay-shared # Shared volume for state files
58+
environment:
59+
- PULSE_SERVER=unix:/run/user/1000/pulse/native
60+
- AIRPLAY_NAME=${AIRPLAY_NAME:-HomeSpeaker}
61+
command: |
62+
sh -c "
63+
# Set AirPlay name from environment variable
64+
AIRPLAY_NAME=\"$${AIRPLAY_NAME:-HomeSpeaker}\"
65+
66+
# Create state management scripts inside container
67+
cat > /usr/local/bin/airplay-start.sh << 'EOF'
68+
#!/bin/sh
69+
echo 'ACTIVE' > /tmp/airplay-shared/state
70+
echo 'AirPlay session started' > /tmp/airplay-shared/log
71+
EOF
72+
73+
cat > /usr/local/bin/airplay-stop.sh << 'EOF'
74+
#!/bin/sh
75+
echo 'INACTIVE' > /tmp/airplay-shared/state
76+
echo 'AirPlay session ended' > /tmp/airplay-shared/log
77+
EOF
78+
79+
chmod +x /usr/local/bin/airplay-start.sh
80+
chmod +x /usr/local/bin/airplay-stop.sh
81+
82+
# Update the config file with the correct name
83+
sed -i \"s/name = \\\"HomeSpeaker\\\"/name = \\\"$$AIRPLAY_NAME\\\"/g\" /etc/shairport-sync.conf
84+
85+
# Start shairport-sync
86+
exec shairport-sync -c /etc/shairport-sync.conf
87+
"
88+
depends_on:
89+
- homespeaker.server2
90+
91+
networks:
92+
speakernet:
93+
94+
volumes:
95+
airplay-shared:

0 commit comments

Comments
 (0)