Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,35 @@ jobs:
if [ -d "$HERE/usr/lib/gio/modules" ]; then
export GIO_MODULE_DIR="$HERE/usr/lib/gio/modules"
fi

# Handle legacy OpenGL (< 3.0 / missing glBlitFramebuffer) or explicit flags
USE_SOFTWARE=0
for arg in "$@"; do
case "$arg" in
--no-enable-impeller|--disable-impeller|--enable-software-rendering|--software)
USE_SOFTWARE=1
;;
esac
done

if [ "$LASTWAVE_DISABLE_IMPELLER" = "1" ] || [ "$LASTWAVE_FORCE_SOFTWARE" = "1" ]; then
USE_SOFTWARE=1
fi

if [ "$USE_SOFTWARE" = "0" ] && command -v glxinfo >/dev/null 2>&1; then
GL_VER="$(glxinfo 2>/dev/null | grep -i "OpenGL version string:" | head -n1 | sed -E 's/.*OpenGL version string: ([0-9]+\.[0-9]+).*/\1/')"
if [ -n "$GL_VER" ]; then
MAJOR="$(echo "$GL_VER" | cut -d. -f1)"
if [ "$MAJOR" -lt 3 ] 2>/dev/null; then
USE_SOFTWARE=1
fi
fi
fi

if [ "$USE_SOFTWARE" = "1" ]; then
export LIBGL_ALWAYS_SOFTWARE=1
fi

exec "$HERE/AppRun.real" "$@"
EOF
chmod +x AppDir/AppRun
Expand Down
48 changes: 33 additions & 15 deletions lib/features/audio_output/output_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class AudioOutputController extends StateNotifier<AudioOutputState> {
final WasapiEngine _engine;
final FormatNegotiator _negotiator;
StreamSubscription<Map<String, dynamic>>? _hotplug;
Timer? _hotplugDebounce;
bool _attached = false;
String? _lastLogged;
// Fingerprint of every input _rebuildPath consumes, so position-only
Expand Down Expand Up @@ -103,6 +104,8 @@ class AudioOutputController extends StateNotifier<AudioOutputState> {

@override
void dispose() {
_hotplugDebounce?.cancel();
_pendingHotplugEvents.clear();
_hotplug?.cancel();
super.dispose();
}
Expand Down Expand Up @@ -225,25 +228,40 @@ class AudioOutputController extends StateNotifier<AudioOutputState> {
}
}

final List<({String reason, String id})> _pendingHotplugEvents = [];

Future<void> _onHotplug(Map<String, dynamic> event) async {
final reason = event['reason']?.toString() ?? '';
final id = event['id']?.toString() ?? '';
wasapiLog('Device event: $reason $id');
final selected = state.selectedId;
await refreshDevices();
if (reason == 'removed' && selected.isNotEmpty && selected == id) {
await _ref.read(playbackServiceProvider.notifier).pause();
state = state.copyWith(
path: state.path.copyWith(
reason: BitPerfectReason.deviceUnavailable,
error: 'DAC disconnected',
),
);
return;
}
if (reason == 'added' || reason == 'default' || reason == 'state') {
await applyToPlayer();
}
_pendingHotplugEvents.add((reason: reason, id: id));
_hotplugDebounce?.cancel();
_hotplugDebounce = Timer(const Duration(milliseconds: 300), () async {
final events =
List<({String reason, String id})>.from(_pendingHotplugEvents);
_pendingHotplugEvents.clear();
final selected = state.selectedId;
await refreshDevices();
final wasRemoved = events.any((e) =>
e.reason == 'removed' && selected.isNotEmpty && selected == e.id);
final shouldApply = events.any((e) =>
e.reason == 'added' ||
e.reason == 'default' ||
e.reason == 'state' ||
e.reason == 'property');
if (shouldApply || wasRemoved) {
await applyToPlayer();
}
if (wasRemoved) {
await _ref.read(playbackServiceProvider.notifier).pause();
state = state.copyWith(
path: state.path.copyWith(
reason: BitPerfectReason.deviceUnavailable,
error: 'DAC disconnected',
),
);
}
});
}

/// True only when every input _rebuildPath reads is unchanged, so the
Expand Down
98 changes: 61 additions & 37 deletions lib/features/innertube/innertube_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,25 @@ class InnerTubeMusicApi {
};

static const List<PlayerClient> playerClients = [
// Limusic fast order: direct-URL clients first (no cipher/poToken),
// ciphered web clients last. The resolver tries VISIONOS alone
// first, then at most 2 more fallbacks — never the full table.
// Direct-URL clients first (no cipher/poToken botguard blockage).
PlayerClient(
'ANDROID',
'21.26.364',
'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'com.google.android.youtube/21.26.364 (Linux; U; Android 11) gzip',
osName: 'Android',
osVersion: '11',
),
PlayerClient(
'IOS',
'21.26.4',
'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc',
'com.google.ios.youtube/21.26.4 (iPhone16,2; U; CPU iOS 18_3_2;)',
osName: 'iPhone',
osVersion: '18.3.2.22D82',
deviceMake: 'Apple',
deviceModel: 'iPhone16,2',
),
PlayerClient(
'VISIONOS',
'0.1',
Expand Down Expand Up @@ -587,24 +603,6 @@ class InnerTubeMusicApi {
'AIzaSyAO_FJ2SlqAz8GlBg1fA54p0wDE7Xk80mU',
'Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/76.0.3809.146 TV Safari/537.36',
),
PlayerClient(
'IOS',
'21.26.4',
'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc',
'com.google.ios.youtube/21.26.4 (iPhone16,2; U; CPU iOS 18_3_2;)',
osName: 'iPhone',
osVersion: '18.3.2.22D82',
deviceMake: 'Apple',
deviceModel: 'iPhone16,2',
),
PlayerClient(
'ANDROID',
'21.26.364',
'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'com.google.android.youtube/21.26.364 (Linux; U; Android 11) gzip',
osName: 'Android',
osVersion: '11',
),
PlayerClient(
'ANDROID_CREATOR',
'24.32.100',
Expand Down Expand Up @@ -697,7 +695,7 @@ class InnerTubeMusicApi {
static const int _unknownExpiryTtlMs = 5 * 60 * 1000;
// Limusic fast path: one preferred direct-URL client first,
// then at most 2 limited fallbacks. Never fan out to the full table.
static const String _fastPreferredClient = 'VISIONOS';
static const String _fastPreferredClient = 'ANDROID';
static const int _maxStreamClients = 3;
static const Duration _fastClientTimeout = Duration(seconds: 8);

Expand Down Expand Up @@ -4363,14 +4361,10 @@ static String normalize(String s) {
if (url == null || url.isEmpty) continue;
url = appendPot(url, gvsPoToken);
final mime = format['mimeType']?.toString() ?? '';
if (!mime.toLowerCase().startsWith('audio/')) continue;
final codec = _extractCodec(mime);
if (!_isCompatibleAudio(mime, codec)) continue;
final bitrate =
(format['bitrate'] as num?)?.toInt() ?? 0;
if (!_isCompatibleAudio(
mime.split(';').first, codec)) {
continue;
}
final urlExpiry = _urlExpiryMs(url);
DateTime? expiresAt;
final options = [
Expand All @@ -4392,10 +4386,11 @@ static String normalize(String s) {
? 'AAC'
: 'AUDIO');
final itag = (format['itag'] as num?)?.toInt() ?? -1;
final cleanMime = mime.split(';').first.trim();
candidates.add(_Candidate(
stream: ResolvedStream(
url: url,
mimeType: mime.split(';').first,
mimeType: cleanMime,
bitrateKbps: (bitrate / 1000).round(),
audioCodec: codecLabel,
cacheKey:
Expand All @@ -4411,7 +4406,7 @@ static String normalize(String s) {
}
candidates.sort((a, b) {
final adaptiveOrder =
(a.adaptive ? 1 : 0).compareTo(b.adaptive ? 1 : 0);
(b.adaptive ? 1 : 0).compareTo(a.adaptive ? 1 : 0);
if (adaptiveOrder != 0) return adaptiveOrder;
return b.bitrate.compareTo(a.bitrate);
});
Expand Down Expand Up @@ -4493,15 +4488,17 @@ static String normalize(String s) {
}();
if (url == null) continue;
final mime = f['mimeType']?.toString() ?? '';
if (!mime.toLowerCase().startsWith('audio/')) {
final codec = _extractCodec(mime);
if (!_isCompatibleAudio(mime, codec)) {
continue;
}
final bitrate =
(f['bitrate'] as num?)?.toInt() ?? 0;
final cleanMime = mime.split(';').first.trim();
collected.add(_Candidate(
stream: ResolvedStream(
url: url,
mimeType: mime.split(';').first,
mimeType: cleanMime,
bitrateKbps: (bitrate / 1000).round(),
audioCodec: 'AUDIO',
cacheKey: 'yt-dl:$videoId:${f['itag']}',
Expand Down Expand Up @@ -4859,27 +4856,54 @@ static String normalize(String s) {
static String _take40(String s) =>
s.length <= 40 ? s : s.substring(0, 40);

static bool _hasVideoCodec(String mime) {
final lower = mime.toLowerCase();
return lower.contains('avc1') ||
lower.contains('avc3') ||
lower.contains('hvc1') ||
lower.contains('hev1') ||
lower.contains('vp8') ||
lower.contains('vp9') ||
lower.contains('vp09') ||
lower.contains('av01') ||
lower.contains('mp4v') ||
lower.contains('theora');
}

static bool _isCompatibleAudio(String mime, String? codec) {
final m = mime.toLowerCase();
final c = (codec ?? '').toLowerCase();
if (m == 'audio/webm') {
if (_hasVideoCodec(m) || _hasVideoCodec(c)) return false;
if (m.startsWith('video/')) return false;
final base = m.split(';').first.trim();
if (base == 'audio/webm') {
return c.isEmpty || c.contains('opus') || c.contains('vorbis');
}
if (m == 'audio/mp4' || m == 'audio/m4a') {
if (base == 'audio/mp4' || base == 'audio/m4a') {
return c.isEmpty || c.contains('mp4a') || c.contains('aac');
}
if (m == 'audio/ogg') {
if (base == 'audio/ogg') {
return c.isEmpty || c.contains('opus') || c.contains('vorbis');
}
if (m == 'audio/mpeg') return true;
if (base == 'audio/mpeg') return true;
return false;
}

static String? _extractCodec(String mime) {
final m = _codecPattern.firstMatch(mime)?.group(1);
if (m == null) return null;
final first = m.split(',').first.trim();
return first.isEmpty ? null : first;
final parts =
m.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty);
for (final p in parts) {
final lp = p.toLowerCase();
if (lp.contains('mp4a') ||
lp.contains('aac') ||
lp.contains('opus') ||
lp.contains('vorbis')) {
return p;
}
}
return parts.isEmpty ? null : parts.first;
}

static int? _urlExpiryMs(String url) {
Expand Down
23 changes: 17 additions & 6 deletions lib/features/innertube/signature_decipher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,24 @@ class SignatureDecipher {
),
).timeout(const Duration(seconds: 15));
final html = res.data ?? '';
jsUrl = RegExp(
r'"jsUrl":"(/s/player/[A-Za-z0-9]+/player_ias\.vflset/[A-Za-z_-]+/base\.js)"')
.firstMatch(html)
?.group(1);
jsUrl ??= RegExp(r'"jsUrl"\s*:\s*"([^"]+)"')
final match =
RegExp(r'(/s/player/[A-Za-z0-9_./-]+/base\.js)').firstMatch(html);
jsUrl = match?.group(1);
if (jsUrl == null) {
final jsonMatch =
RegExp(r'"jsUrl"\s*:\s*"((?:\\.|[^"\\])*)"').firstMatch(html);
if (jsonMatch != null) {
try {
jsUrl = jsonDecode('"${jsonMatch.group(1)}"') as String?;
} catch (_) {
jsUrl = jsonMatch.group(1)?.replaceAll(r'\/', '/');
}
}
}
jsUrl ??= RegExp(r'((?:\\?/s\\?/player\\?/[A-Za-z0-9_./\\-]+/base\.js))')
.firstMatch(html)
?.group(1);
?.group(1)
?.replaceAll(r'\/', '/');
if (jsUrl != null) break;
} catch (_) {}
}
Expand Down
Loading