Compare commits
14 Commits
3e72716350
...
80e16f4fa2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80e16f4fa2 | ||
|
|
464666c01a | ||
|
|
4db9a95a91 | ||
|
|
0e58cd0e99 | ||
|
|
3cce2868de | ||
|
|
894b0d7e5e | ||
|
|
7befbca8e5 | ||
|
|
ccf84c568e | ||
|
|
fdb5ed8f56 | ||
|
|
55871e3cdd | ||
|
|
0aa44520ac | ||
|
|
c5a72cd44c | ||
|
|
8b6cc11486 | ||
|
|
cee65b5f2f |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 39 KiB |
BIN
assets/mobile-screenshots/android-6.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 750 KiB After Width: | Height: | Size: 1006 KiB |
@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:spotube/services/logger/logger.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -7,9 +6,7 @@ import 'package:spotify/spotify.dart' hide Playlist;
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/services/audio_player/custom_player.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:media_kit/media_kit.dart' as mk;
|
||||
|
||||
import 'package:spotube/services/audio_player/playback_state.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
@ -17,40 +14,40 @@ import 'package:spotube/utils/platform.dart';
|
||||
part 'audio_players_streams_mixin.dart';
|
||||
part 'audio_player_impl.dart';
|
||||
|
||||
// Constants class for shared constants like port and addresses
|
||||
class Constants {
|
||||
static const defaultServerPort = 8080;
|
||||
static const defaultLocalHost = "localhost";
|
||||
}
|
||||
|
||||
// Helper to get network address based on the platform
|
||||
String getNetworkAddress() {
|
||||
return kIsWindows ? Constants.defaultLocalHost : InternetAddress.anyIPv4.address;
|
||||
}
|
||||
|
||||
// Helper to get URI for a given track
|
||||
String getUriForTrack(Track track, int serverPort) {
|
||||
return track is LocalTrack
|
||||
? track.path
|
||||
: "http://${getNetworkAddress()}:$serverPort/stream/${track.id}";
|
||||
}
|
||||
|
||||
// SpotubeMedia class handling media creation logic
|
||||
class SpotubeMedia extends mk.Media {
|
||||
final Track track;
|
||||
static int serverPort = Constants.defaultServerPort;
|
||||
|
||||
static int serverPort = 0;
|
||||
|
||||
SpotubeMedia(
|
||||
this.track, {
|
||||
Map<String, dynamic>? extras,
|
||||
super.httpHeaders,
|
||||
}) : super(
|
||||
track is LocalTrack
|
||||
? track.path
|
||||
: "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}",
|
||||
SpotubeMedia(this.track, {Map<String, dynamic>? extras, super.httpHeaders})
|
||||
: super(
|
||||
getUriForTrack(track, serverPort),
|
||||
extras: {
|
||||
...?extras,
|
||||
"track": switch (track) {
|
||||
LocalTrack() => track.toJson(),
|
||||
SourcedTrack() => track.toJson(),
|
||||
_ => track.toJson(),
|
||||
},
|
||||
"track": track.toJson(),
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
String get uri {
|
||||
return switch (track) {
|
||||
/// [super.uri] must be used instead of [track.path] to prevent wrong
|
||||
/// path format exceptions in Windows causing [extras] to be null
|
||||
LocalTrack() => super.uri,
|
||||
_ =>
|
||||
"http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:"
|
||||
"$serverPort/stream/${track.id}",
|
||||
};
|
||||
}
|
||||
String get uri => getUriForTrack(track, serverPort);
|
||||
|
||||
factory SpotubeMedia.fromMedia(mk.Media media) {
|
||||
final track = media.uri.startsWith("http")
|
||||
@ -62,102 +59,100 @@ class SpotubeMedia extends mk.Media {
|
||||
httpHeaders: media.httpHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
// @override
|
||||
// operator ==(Object other) {
|
||||
// if (other is! SpotubeMedia) return false;
|
||||
|
||||
// final isLocal = track is LocalTrack && other.track is LocalTrack;
|
||||
// return isLocal
|
||||
// ? (other.track as LocalTrack).path == (track as LocalTrack).path
|
||||
// : other.track.id == track.id;
|
||||
// }
|
||||
|
||||
// @override
|
||||
// int get hashCode => track is LocalTrack
|
||||
// ? (track as LocalTrack).path.hashCode
|
||||
// : track.id.hashCode;
|
||||
}
|
||||
|
||||
abstract class AudioPlayerInterface {
|
||||
final CustomPlayer _mkPlayer;
|
||||
// Factory class to create SpotubeMedia instances
|
||||
class SpotubeMediaFactory {
|
||||
static SpotubeMedia create(Track track, {Map<String, dynamic>? extras, Map<String, String>? headers}) {
|
||||
return SpotubeMedia(track, extras: extras, httpHeaders: headers);
|
||||
}
|
||||
}
|
||||
|
||||
AudioPlayerInterface()
|
||||
: _mkPlayer = CustomPlayer(
|
||||
configuration: const mk.PlayerConfiguration(
|
||||
title: "Spotube",
|
||||
logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error,
|
||||
),
|
||||
) {
|
||||
_mkPlayer.stream.error.listen((event) {
|
||||
// Playback state management class
|
||||
class PlaybackStateManager {
|
||||
final CustomPlayer player;
|
||||
|
||||
PlaybackStateManager(this.player);
|
||||
|
||||
bool get isPlaying => player.state.playing;
|
||||
bool get isPaused => !player.state.playing;
|
||||
bool get isStopped => player.state.playlist.medias.isEmpty;
|
||||
|
||||
Duration get duration => player.state.duration;
|
||||
Duration get position => player.state.position;
|
||||
Duration get bufferedPosition => player.state.buffer;
|
||||
bool get isShuffled => player.shuffled;
|
||||
double get volume => player.state.volume / 100;
|
||||
|
||||
Future<List<mk.AudioDevice>> get devices async => player.state.audioDevices;
|
||||
Future<mk.AudioDevice> get selectedDevice async => player.state.audioDevice;
|
||||
|
||||
PlaylistMode get loopMode => player.state.playlistMode;
|
||||
}
|
||||
|
||||
// Main AudioPlayerInterface class with DI and error handling
|
||||
abstract class AudioPlayerInterface {
|
||||
final CustomPlayer player;
|
||||
final PlaybackStateManager stateManager;
|
||||
|
||||
AudioPlayerInterface(this.player)
|
||||
: stateManager = PlaybackStateManager(player) {
|
||||
player.stream.error.listen((event) {
|
||||
AppLogger.reportError(event, StackTrace.current);
|
||||
// Retry or fallback mechanism can be added here
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the current platform supports the audioplayers plugin
|
||||
static const bool _mkSupportedPlatform = true;
|
||||
|
||||
bool get mkSupportedPlatform => _mkSupportedPlatform;
|
||||
|
||||
Duration get duration {
|
||||
return _mkPlayer.state.duration;
|
||||
// High-level control methods for playback
|
||||
Future<void> play() async {
|
||||
try {
|
||||
await player.play();
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Playlist get playlist {
|
||||
return _mkPlayer.state.playlist;
|
||||
Future<void> pause() async {
|
||||
try {
|
||||
await player.pause();
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Duration get position {
|
||||
return _mkPlayer.state.position;
|
||||
Future<void> stop() async {
|
||||
try {
|
||||
await player.stop();
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Duration get bufferedPosition {
|
||||
return _mkPlayer.state.buffer;
|
||||
Future<void> seek(Duration position) async {
|
||||
try {
|
||||
await player.seek(position);
|
||||
} catch (e) {
|
||||
AppLogger.reportError(e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<mk.AudioDevice> get selectedDevice async {
|
||||
return _mkPlayer.state.audioDevice;
|
||||
}
|
||||
|
||||
Future<List<mk.AudioDevice>> get devices async {
|
||||
return _mkPlayer.state.audioDevices;
|
||||
}
|
||||
|
||||
bool get hasSource {
|
||||
return _mkPlayer.state.playlist.medias.isNotEmpty;
|
||||
}
|
||||
|
||||
// states
|
||||
bool get isPlaying {
|
||||
return _mkPlayer.state.playing;
|
||||
}
|
||||
|
||||
bool get isPaused {
|
||||
return !_mkPlayer.state.playing;
|
||||
}
|
||||
|
||||
bool get isStopped {
|
||||
return !hasSource;
|
||||
}
|
||||
|
||||
Future<bool> get isCompleted async {
|
||||
return _mkPlayer.state.completed;
|
||||
}
|
||||
|
||||
bool get isShuffled {
|
||||
return _mkPlayer.shuffled;
|
||||
}
|
||||
|
||||
PlaylistMode get loopMode {
|
||||
return _mkPlayer.state.playlistMode;
|
||||
}
|
||||
|
||||
/// Returns the current volume of the player, between 0 and 1
|
||||
double get volume {
|
||||
return _mkPlayer.state.volume / 100;
|
||||
}
|
||||
|
||||
bool get isBuffering {
|
||||
return _mkPlayer.state.buffering;
|
||||
}
|
||||
// Access state information through the state manager
|
||||
bool get isPlaying => stateManager.isPlaying;
|
||||
bool get isPaused => stateManager.isPaused;
|
||||
bool get isStopped => stateManager.isStopped;
|
||||
Duration get duration => stateManager.duration;
|
||||
Duration get position => stateManager.position;
|
||||
Duration get bufferedPosition => stateManager.bufferedPosition;
|
||||
bool get isShuffled => stateManager.isShuffled;
|
||||
double get volume => stateManager.volume;
|
||||
Future<List<mk.AudioDevice>> get devices => stateManager.devices;
|
||||
Future<mk.AudioDevice> get selectedDevice => stateManager.selectedDevice;
|
||||
PlaylistMode get loopMode => stateManager.loopMode;
|
||||
}
|
||||
|
||||
// Example implementation for a specific platform/player
|
||||
class MyAudioPlayer extends AudioPlayerInterface {
|
||||
MyAudioPlayer(CustomPlayer player) : super(player);
|
||||
|
||||
// Additional functionality can be added here if necessary
|
||||
}
|
||||
|
||||
@ -1,45 +1,28 @@
|
||||
part of 'audio_player.dart';
|
||||
|
||||
final audioPlayer = SpotubeAudioPlayer();
|
||||
|
||||
class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||
with SpotubeAudioPlayersStreams {
|
||||
Future<void> pause() async {
|
||||
await _mkPlayer.pause();
|
||||
}
|
||||
class SpotubeAudioPlayer extends AudioPlayerInterface with SpotubeAudioPlayersStreams {
|
||||
// Playback control methods
|
||||
Future<void> pause() async => await player.pause();
|
||||
|
||||
Future<void> resume() async {
|
||||
await _mkPlayer.play();
|
||||
}
|
||||
Future<void> resume() async => await player.play();
|
||||
|
||||
Future<void> stop() async {
|
||||
await _mkPlayer.stop();
|
||||
}
|
||||
Future<void> stop() async => await player.stop();
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
await _mkPlayer.seek(position);
|
||||
}
|
||||
Future<void> seek(Duration position) async => await player.seek(position);
|
||||
|
||||
/// Volume is between 0 and 1
|
||||
/// Set volume between 0 and 1
|
||||
Future<void> setVolume(double volume) async {
|
||||
assert(volume >= 0 && volume <= 1);
|
||||
await _mkPlayer.setVolume(volume * 100);
|
||||
await player.setVolume(volume * 100);
|
||||
}
|
||||
|
||||
Future<void> setSpeed(double speed) async {
|
||||
await _mkPlayer.setRate(speed);
|
||||
}
|
||||
Future<void> setSpeed(double speed) async => await player.setRate(speed);
|
||||
|
||||
Future<void> setAudioDevice(mk.AudioDevice device) async {
|
||||
await _mkPlayer.setAudioDevice(device);
|
||||
}
|
||||
Future<void> setAudioDevice(mk.AudioDevice device) async => await player.setAudioDevice(device);
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _mkPlayer.dispose();
|
||||
}
|
||||
|
||||
// Playlist related
|
||||
Future<void> dispose() async => await player.dispose();
|
||||
|
||||
// Playlist control methods
|
||||
Future<void> openPlaylist(
|
||||
List<mk.Media> tracks, {
|
||||
bool autoPlay = true,
|
||||
@ -47,88 +30,59 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||
}) async {
|
||||
assert(tracks.isNotEmpty);
|
||||
assert(initialIndex <= tracks.length - 1);
|
||||
await _mkPlayer.open(
|
||||
|
||||
await player.open(
|
||||
mk.Playlist(tracks, index: initialIndex),
|
||||
play: autoPlay,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> get sources {
|
||||
return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList();
|
||||
}
|
||||
// Helper methods for playlist sources
|
||||
List<String> get sources => player.state.playlist.medias.map((e) => e.uri).toList();
|
||||
|
||||
String? get currentSource {
|
||||
if (_mkPlayer.state.playlist.index == -1) return null;
|
||||
return _mkPlayer.state.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.state.playlist.index)
|
||||
?.uri;
|
||||
final index = player.state.playlist.index;
|
||||
if (index == -1) return null;
|
||||
return player.state.playlist.medias.elementAtOrNull(index)?.uri;
|
||||
}
|
||||
|
||||
String? get nextSource {
|
||||
if (loopMode == PlaylistMode.loop &&
|
||||
_mkPlayer.state.playlist.index ==
|
||||
_mkPlayer.state.playlist.medias.length - 1) {
|
||||
return sources.first;
|
||||
}
|
||||
final isLastTrack = player.state.playlist.index == player.state.playlist.medias.length - 1;
|
||||
if (loopMode == PlaylistMode.loop && isLastTrack) return sources.first;
|
||||
|
||||
return _mkPlayer.state.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.state.playlist.index + 1)
|
||||
?.uri;
|
||||
return player.state.playlist.medias.elementAtOrNull(player.state.playlist.index + 1)?.uri;
|
||||
}
|
||||
|
||||
String? get previousSource {
|
||||
if (loopMode == PlaylistMode.loop && _mkPlayer.state.playlist.index == 0) {
|
||||
return sources.last;
|
||||
if (loopMode == PlaylistMode.loop && player.state.playlist.index == 0) return sources.last;
|
||||
|
||||
return player.state.playlist.medias.elementAtOrNull(player.state.playlist.index - 1)?.uri;
|
||||
}
|
||||
|
||||
return _mkPlayer.state.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.state.playlist.index - 1)
|
||||
?.uri;
|
||||
}
|
||||
int get currentIndex => player.state.playlist.index;
|
||||
|
||||
int get currentIndex => _mkPlayer.state.playlist.index;
|
||||
// Playlist navigation methods
|
||||
Future<void> skipToNext() async => await player.next();
|
||||
|
||||
Future<void> skipToNext() async {
|
||||
await _mkPlayer.next();
|
||||
}
|
||||
Future<void> skipToPrevious() async => await player.previous();
|
||||
|
||||
Future<void> skipToPrevious() async {
|
||||
await _mkPlayer.previous();
|
||||
}
|
||||
Future<void> jumpTo(int index) async => await player.jump(index);
|
||||
|
||||
Future<void> jumpTo(int index) async {
|
||||
await _mkPlayer.jump(index);
|
||||
}
|
||||
// Playlist management methods
|
||||
Future<void> addTrack(mk.Media media) async => await player.add(media);
|
||||
|
||||
Future<void> addTrack(mk.Media media) async {
|
||||
await _mkPlayer.add(media);
|
||||
}
|
||||
Future<void> addTrackAt(mk.Media media, int index) async => await player.insert(index, media);
|
||||
|
||||
Future<void> addTrackAt(mk.Media media, int index) async {
|
||||
await _mkPlayer.insert(index, media);
|
||||
}
|
||||
Future<void> removeTrack(int index) async => await player.remove(index);
|
||||
|
||||
Future<void> removeTrack(int index) async {
|
||||
await _mkPlayer.remove(index);
|
||||
}
|
||||
Future<void> moveTrack(int from, int to) async => await player.move(from, to);
|
||||
|
||||
Future<void> moveTrack(int from, int to) async {
|
||||
await _mkPlayer.move(from, to);
|
||||
}
|
||||
Future<void> clearPlaylist() async => await player.stop();
|
||||
|
||||
Future<void> clearPlaylist() async {
|
||||
_mkPlayer.stop();
|
||||
}
|
||||
// Shuffle and loop mode control
|
||||
Future<void> setShuffle(bool shuffle) async => await player.setShuffle(shuffle);
|
||||
|
||||
Future<void> setShuffle(bool shuffle) async {
|
||||
await _mkPlayer.setShuffle(shuffle);
|
||||
}
|
||||
Future<void> setLoopMode(PlaylistMode loop) async => await player.setPlaylistMode(loop);
|
||||
|
||||
Future<void> setLoopMode(PlaylistMode loop) async {
|
||||
await _mkPlayer.setPlaylistMode(loop);
|
||||
}
|
||||
|
||||
Future<void> setAudioNormalization(bool normalize) async {
|
||||
await _mkPlayer.setAudioNormalization(normalize);
|
||||
}
|
||||
Future<void> setAudioNormalization(bool normalize) async => await player.setAudioNormalization(normalize);
|
||||
}
|
||||
|
||||
@ -4,43 +4,52 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:spotube/services/audio_player/playback_state.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
/// MediaKit [Player] by default doesn't have a state stream.
|
||||
/// This class adds a state stream to the [Player] class.
|
||||
class CustomPlayer extends Player {
|
||||
final StreamController<AudioPlaybackState> _playerStateStream;
|
||||
final StreamController<bool> _shuffleStream;
|
||||
final StreamController<AudioPlaybackState> _playerStateStream = StreamController.broadcast();
|
||||
final StreamController<bool> _shuffleStream = StreamController.broadcast();
|
||||
|
||||
late final List<StreamSubscription> _subscriptions;
|
||||
|
||||
bool _shuffled;
|
||||
bool _shuffled = false;
|
||||
int _androidAudioSessionId = 0;
|
||||
String _packageName = "";
|
||||
AndroidAudioManager? _androidAudioManager;
|
||||
|
||||
CustomPlayer({super.configuration})
|
||||
: _playerStateStream = StreamController.broadcast(),
|
||||
_shuffleStream = StreamController.broadcast(),
|
||||
_shuffled = false {
|
||||
CustomPlayer({super.configuration}) {
|
||||
nativePlayer.setProperty("network-timeout", "120");
|
||||
|
||||
_subscriptions = [
|
||||
stream.buffering.listen((event) {
|
||||
_playerStateStream.add(AudioPlaybackState.buffering);
|
||||
}),
|
||||
stream.playing.listen((playing) {
|
||||
if (playing) {
|
||||
_playerStateStream.add(AudioPlaybackState.playing);
|
||||
} else {
|
||||
_playerStateStream.add(AudioPlaybackState.paused);
|
||||
_initPlatformSpecificSetup();
|
||||
_listenToPlayerEvents();
|
||||
}
|
||||
|
||||
Future<void> _initPlatformSpecificSetup() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
_packageName = packageInfo.packageName;
|
||||
|
||||
if (kIsAndroid) {
|
||||
_androidAudioManager = AndroidAudioManager();
|
||||
_androidAudioSessionId = await _androidAudioManager!.generateAudioSessionId();
|
||||
notifyAudioSessionUpdate(true);
|
||||
await _setAndroidAudioSession();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setAndroidAudioSession() async {
|
||||
await nativePlayer.setProperty("audiotrack-session-id", _androidAudioSessionId.toString());
|
||||
await nativePlayer.setProperty("ao", "audiotrack,opensles,");
|
||||
}
|
||||
|
||||
void _listenToPlayerEvents() {
|
||||
_subscriptions = [
|
||||
stream.buffering.listen((_) => _playerStateStream.add(AudioPlaybackState.buffering)),
|
||||
stream.playing.listen((playing) {
|
||||
_playerStateStream.add(playing ? AudioPlaybackState.playing : AudioPlaybackState.paused);
|
||||
}),
|
||||
stream.completed.listen((isCompleted) async {
|
||||
if (!isCompleted) return;
|
||||
stream.completed.listen((isCompleted) {
|
||||
if (isCompleted) {
|
||||
_playerStateStream.add(AudioPlaybackState.completed);
|
||||
}
|
||||
}),
|
||||
stream.playlist.listen((event) {
|
||||
if (event.medias.isEmpty) {
|
||||
@ -51,23 +60,6 @@ class CustomPlayer extends Player {
|
||||
AppLogger.reportError('[MediaKitError] \n$event', StackTrace.current);
|
||||
}),
|
||||
];
|
||||
PackageInfo.fromPlatform().then((packageInfo) {
|
||||
_packageName = packageInfo.packageName;
|
||||
});
|
||||
if (kIsAndroid) {
|
||||
_androidAudioManager = AndroidAudioManager();
|
||||
AudioSession.instance.then((s) async {
|
||||
_androidAudioSessionId =
|
||||
await _androidAudioManager!.generateAudioSessionId();
|
||||
notifyAudioSessionUpdate(true);
|
||||
|
||||
await nativePlayer.setProperty(
|
||||
"audiotrack-session-id",
|
||||
_androidAudioSessionId.toString(),
|
||||
);
|
||||
await nativePlayer.setProperty("ao", "audiotrack,opensles,");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> notifyAudioSessionUpdate(bool active) async {
|
||||
@ -79,7 +71,7 @@ class CustomPlayer extends Player {
|
||||
: "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION",
|
||||
data: {
|
||||
"android.media.extra.AUDIO_SESSION": _androidAudioSessionId,
|
||||
"android.media.extra.PACKAGE_NAME": _packageName
|
||||
"android.media.extra.PACKAGE_NAME": _packageName,
|
||||
},
|
||||
),
|
||||
);
|
||||
@ -90,6 +82,7 @@ class CustomPlayer extends Player {
|
||||
|
||||
Stream<AudioPlaybackState> get playerStateStream => _playerStateStream.stream;
|
||||
Stream<bool> get shuffleStream => _shuffleStream.stream;
|
||||
|
||||
Stream<int> get indexChangeStream {
|
||||
int oldIndex = state.playlist.index;
|
||||
return stream.playlist.map((event) => event.index).where((newIndex) {
|
||||
@ -106,6 +99,8 @@ class CustomPlayer extends Player {
|
||||
_shuffled = shuffle;
|
||||
await super.setShuffle(shuffle);
|
||||
_shuffleStream.add(shuffle);
|
||||
|
||||
// Ensure delay before rearranging playlist
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (shuffle) {
|
||||
await move(state.playlist.index, 0);
|
||||
@ -115,7 +110,6 @@ class CustomPlayer extends Player {
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
await super.stop();
|
||||
|
||||
_shuffled = false;
|
||||
_playerStateStream.add(AudioPlaybackState.stopped);
|
||||
_shuffleStream.add(false);
|
||||
@ -123,10 +117,10 @@ class CustomPlayer extends Player {
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
for (var element in _subscriptions) {
|
||||
element.cancel();
|
||||
}
|
||||
await Future.wait(_subscriptions.map((sub) => sub.cancel()));
|
||||
await notifyAudioSessionUpdate(false);
|
||||
await _playerStateStream.close();
|
||||
await _shuffleStream.close();
|
||||
return super.dispose();
|
||||
}
|
||||
|
||||
@ -138,10 +132,9 @@ class CustomPlayer extends Player {
|
||||
}
|
||||
|
||||
Future<void> setAudioNormalization(bool normalize) async {
|
||||
if (normalize) {
|
||||
await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5');
|
||||
} else {
|
||||
await nativePlayer.setProperty('af', '');
|
||||
}
|
||||
await nativePlayer.setProperty(
|
||||
'af',
|
||||
normalize ? 'dynaudnorm=g=5:f=250:r=0.9:p=0.5' : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image type="source">
|
||||
https://rawcdn.githack.com/KRTirtho/spotube/62055018feade0b895663a0bfc5f85f265ae2154/assets/spotube-screenshot.png
|
||||
https://rawcdn.githack.com/KRTirtho/spotube/refs/heads/master/assets/spotube-screenshot.png
|
||||
</image>
|
||||
<caption>Spotube screenshot</caption>
|
||||
</screenshot>
|
||||
|
||||
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 39 KiB |
BIN
metadata/en-US/images/phoneScreenshots/android-6.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 39 KiB |
BIN
metadata/tr/images/phoneScreenshots/android-6.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
@ -60,5 +60,15 @@
|
||||
"remark-reading-time": "^1.0.1",
|
||||
"svelte-fa": "^4.0.2",
|
||||
"svelte-markdown": "^0.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.4.0+sha512.6b849d0787d97f8f4e1f03a9b8ff8f038e79e153d6f11ae539ae7c435ff9e796df6a862c991502695c7f9e8fac8aeafc1ac5a8dab47e36148d183832d886dd52",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@fortawesome/fontawesome-common-types",
|
||||
"@fortawesome/free-brands-svg-icons",
|
||||
"@sveltejs/kit",
|
||||
"esbuild",
|
||||
"svelte-preprocess"
|
||||
]
|
||||
}
|
||||
}
|
||||
36
website/posts/from-idea-to-impact.md
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
title: From Idea to Impact
|
||||
author: Prottoy Roy
|
||||
date: 2024-12-22
|
||||
published: true
|
||||
cover_img: /images/from-idea-to-impact/cover.jpg
|
||||
---
|
||||
|
||||
> An school magazine article by the beloved brother of the founder of the Spotube app
|
||||
|
||||
In the vibrant city of Narayanganj, Dhaka, Bangladesh, a young man named Kingkor Roy Tirtho was carving out his path in the world of technology. Currently a second-year Computer Science and Engineering (CSE) student at East West University, Kingkor had always been captivated by the magic of coding. From a young age, he spent countless hours tinkering with computers, teaching himself programming languages and exploring the digital realm.
|
||||
|
||||
Kingkor's passion wasn't just about writing code; it was about solving problems and creating innovative solutions. Inspired by the way technology could enhance everyday life, he dreamed of building apps that would bring joy and convenience to users. His dedication was evident; he often participated in hackathons and coding competitions, where he showcased his talent and creativity.
|
||||
|
||||
The turning point in his journey came when he envisioned an app that would revolutionize music streaming. With millions of people seeking accessible music, he wanted to create a platform that could bridge gaps and provide a seamless experience. Thus, Spotube was born.
|
||||
|
||||
Initially, Kingkor faced numerous challenges. Balancing his academic responsibilities with app development was no easy feat. There were nights filled with coding, debugging, and sleepless hours fueled by caffeine and determination. Despite setbacks and moments of self-doubt, Kingkor remained resilient. He sought feedback, learned from criticisms, and continually iterated on his project.
|
||||
|
||||
As Spotube gained traction, it garnered attention for its user-friendly interface and innovative features. Kingkor’s ability to blend technical skills with an understanding of user needs made the app a hit among music lovers. He received positive reviews, not just for the functionality, but for the passion evident in his work.
|
||||
|
||||
Kingkor’s story is one of perseverance and innovation. He embodies the spirit of a new generation of tech enthusiasts who believe that with dedication, anything is possible. His journey serves as an inspiration to his peers at East West University and beyond, reminding them that the intersection of creativity and technology can lead to remarkable achievements.
|
||||
|
||||
Today, Kingkor continues to evolve as a developer, always looking for ways to improve Spotube and explore new ideas. His story illustrates that genius isn't just about raw talent; it's about hard work, resilience, and the willingness to dream big. Kingkor Roy Tirtho is a shining example of what can be achieved when passion meets perseverance, and he is just getting international attentions.
|
||||
|
||||
Here is some key features of Spotube:
|
||||
|
||||
1. **Seamless Music Streaming**: Spotube offers a smooth streaming experience with a vast library of tracks, allowing users to easily find and play their favorite songs.
|
||||
1. **Offline Listening**: Users can download their favorite tracks for offline playback, making it convenient to enjoy music anytime, anywhere, without relying on an internet connection.
|
||||
1. **User-Friendly Interface**: The app is designed with an intuitive interface, making navigation easy for users of all ages. Its clean layout ensures a pleasant user experience.
|
||||
1. **Cross-Platform Compatibility**: Spotube is accessible on multiple devices, enabling users to enjoy their music on smartphones, tablets, and desktops seamlessly.
|
||||
1. **Personalized Playlists**: Users can create and manage their playlists, helping them curate their listening experience based on their mood and preferences.
|
||||
1. **Social Sharing Features**: The app allows users to share their favorite tracks and playlists with friends and family, fostering a community of music lovers.
|
||||
1. **Regular Updates**: Spotube is continually updated with new features and improvements based on user feedback, reflecting Kingkor's commitment to enhancing the app's performance and user satisfaction.
|
||||
1. **Global Reach**: With its growing popularity, Spotube is gaining attention worldwide, attracting users from various countries and cultures, showcasing Kingkor’s vision of accessible music for everyone. He's recently got mentioned in a Spanish well known magazine for his invention.
|
||||
|
||||
As Spotube continues to evolve, Kingkor Roy Tirtho's innovative approach is positioning him and his app as significant players in the music streaming landscape, capturing the attention of users and industry experts alike.
|
||||
@ -14,7 +14,7 @@
|
||||
<!-- Android Chrome -->
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-6419300932495863"
|
||||
crossorigin="anonymous"></script>
|
||||
data-overlays="bottom" crossorigin="anonymous"></script>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@ -9,3 +9,15 @@
|
||||
src: url('/fonts/AbrilFatface.ttf');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.text-stroke {
|
||||
text-shadow:
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
1px 1px 0 #000,
|
||||
-1px 0 0 #000,
|
||||
1px 0 0 #000,
|
||||
0 -1px 0 #000,
|
||||
0 1px 0 #000;
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@ export const extendedNightlyDownloadLinks: Record<
|
||||
export const ADS_SLOTS = Object.freeze({
|
||||
rootPageDisplay: 5979549631,
|
||||
blogPageInFeed: 3386010031,
|
||||
downloadPageDisplay: 9521642154,
|
||||
downloadPageDisplay: 9928443050,
|
||||
packagePageArticle: 9119323068,
|
||||
// This is being used for rehype-auto-ads in svelte.config.js
|
||||
blogArticlePageArticle: 6788673194,
|
||||
|
||||
@ -53,17 +53,9 @@
|
||||
built with Electron (web technologies)
|
||||
</p>
|
||||
<br />
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=oss.krtirtho.spotube"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
class="-m-2"
|
||||
src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
|
||||
alt="Google PlayStore"
|
||||
width="200"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="https://news.ycombinator.com/item?id=39066136" target="_blank">
|
||||
<img src="https://hackerbadge.vercel.app/api?id=39066136" alt="HackerNews" />
|
||||
</a>
|
||||
<a href="https://flathub.org/apps/com.github.KRTirtho.Spotube" target="_blank">
|
||||
<img
|
||||
@ -73,12 +65,7 @@
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<br />
|
||||
<a href="https://news.ycombinator.com/item?id=39066136" target="_blank">
|
||||
<img src="https://hackerbadge.vercel.app/api?id=39066136" alt="HackerNews" />
|
||||
</a>
|
||||
</div>
|
||||
<br class="hidden md:block" />
|
||||
<div class="flex justify-center">
|
||||
<a href="/downloads" class="flex gap-2 btn variant-filled-primary">
|
||||
Download
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
export let data: PageData;
|
||||
const {
|
||||
Content,
|
||||
meta: { date, title, readingTime, cover_img }
|
||||
meta: { date, title, readingTime, cover_img, author }
|
||||
} = data as Required<PageData>;
|
||||
</script>
|
||||
|
||||
@ -20,7 +20,8 @@
|
||||
: null}
|
||||
style={cover_img ? `background-image: url(/posts/${cover_img});` : ''}
|
||||
>
|
||||
<h1 class={`h1 ${cover_img ? 'text-white' : ''}`}>{title}</h1>
|
||||
<h1 class={`h1 text-stroke ${cover_img ? 'text-white' : ''}`}>{title}</h1>
|
||||
<h4 class={`h4 text-stroke text-gray-400`}>By {author}</h4>
|
||||
<br />
|
||||
<p class={cover_img ? 'text-gray-400' : ''}>{new Date(date).toDateString()}</p>
|
||||
<p class={`mb-16 ${cover_img ? 'text-gray-400' : ''}`}>{readingTime?.text ?? ''}</p>
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export function load(){
|
||||
redirect(301, "/downloads");
|
||||
}
|
||||
BIN
website/static/posts/images/from-idea-to-impact/cover.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |